Skeleton | CoUI
본문으로 건너뛰기
LogoCoUI

Skeleton

콘텐츠 로딩 중 자리 표시자를 표시하는 스켈레톤 컴포넌트

목차

데이터 로딩 중에 콘텐츠 레이아웃을 미리 표시하는 스켈레톤 컴포넌트입니다. 사용자에게 로딩 상태를 시각적으로 전달합니다.

기본 사용법 (Basic Usage)#

// 기본 직사각형 스켈레톤
Skeleton(
  width: 200,
  height: 20,
)

// 원형 스켈레톤 (아바타)
Skeleton.circle(size: 48)

// 텍스트 스켈레톤 (여러 줄)
Skeleton.text(
  skeletonStyle: CoreSkeletonStyle(lines: 3),
)

// 카드 스켈레톤 조합
Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Skeleton(
      width: double.infinity,
      height: 200,
      skeletonStyle: CoreSkeletonStyle(
        borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
      ),
    ),
    Gap(size: CoreSpace.space12),
    Skeleton.text(
      skeletonStyle: CoreSkeletonStyle(lines: 2),
    ),
    Gap(size: CoreSpace.space8),
    Skeleton(width: 100, height: 16),
  ],
)
// 기본 직사각형 스켈레톤 (CSS 크기값 사용)
Skeleton(width: '200px', height: '20px')

// 원형 스켈레톤 (아바타)
Skeleton.circle(size: '48px')

// 텍스트 스켈레톤 (여러 줄)
Skeleton.text(
  skeletonStyle: const CoreSkeletonStyle(lines: 3),
)

// 카드 스켈레톤 조합
div([
  Skeleton(
    width: '100%',
    height: '200px',
    skeletonStyle: const CoreSkeletonStyle(
      borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
    ),
  ),
  div([
    Skeleton(width: '70%', height: '20px'),
    Skeleton(width: '50%', height: '16px'),
  ]),
])

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • API 요청이나 비동기 데이터 로딩 중에 콘텐츠의 대략적인 형태를 미리 보여줄 때
  • 목록 항목, 카드, 텍스트 블록 등 콘텐츠 레이아웃이 예측 가능할 때
  • 로딩 중에 레이아웃 점프(layout shift)를 방지하고 싶을 때

대신 다른 컴포넌트를 사용하세요:

  • Loading: 콘텐츠 구조를 예측할 수 없거나 단순히 진행 중임만 표시할 때
  • EmptyState: 데이터가 없는 상태를 안내할 때 (로딩이 끝난 후)

Props / Parameters#

Skeleton (Flutter) / Skeleton (Web)#

속성타입 (Flutter / Web)기본값설명
width double? / String? null 스켈레톤 너비
height double? / String? null 스켈레톤 높이
variant CoreSkeletonVariant CoreSkeletonVariant.defaultVariant (rectangular) 스켈레톤 형태
skeletonStyle CoreSkeletonStyle? null chrome / dimensional / animation 슬롯

CoreSkeletonStyle 필드#

필드타입설명
backgroundColor CoreColor? Skeleton fill (background) colour override.
borderRadius CoreBorderRadius? Border radius override.
lineSpacing double? Gap between text-variant lines (logical px) — applied to the native Column.spacing paint-API on Flutter and to the flex row-gap inline CSS on Web. null defers to [defaultLineSpacing].
lineHeight double? Per-line height for the text variant (logical px). null defers to [defaultLineHeight]. Applied as Container.height on Flutter and inline height CSS on Web.
lastLineWidth double? Width fraction of the last line in text variant (0.0–1.0).
lines int? Default number of lines for text variant.
duration Duration? Length of one full pulse — rest → peak → rest — on both platforms. null → [defaultDuration]. One value, one meaning: Web runs it as the keyframe cycle and Flutter as one controller period split into the same two halves, so the same override pulses at the same speed everywhere.
curve CoreCubicBezier? Easing of each half of the pulse, as cubic-bezier control points — applied forward to rest → peak and again to peak → rest, the way a CSS timing function applies per keyframe interval. null → [defaultCurve].
fromOpacity double? Pulse animation start opacity (0.0–1.0), multiplied into the fill's own alpha at the resting end — a transparent fill stays transparent.
toOpacity double? Pulse animation end opacity (0.0–1.0), multiplied into the fill's own alpha at the peak.

CoreSkeletonStyle 변형별 기본값 (CoreSkeletonVariantStyle)#

필드rectangularcirculartext
borderRadius CoreBorderRadius.all(CoreRadius.radius8) CoreBorderRadius.all(CoreRadius.radius9999) CoreBorderRadius.all(CoreRadius.radius4)
backgroundColor surfaceContainerHighest surfaceContainerHighest surfaceContainerHighest

팩토리 생성자#

생성자파라미터설명
Skeleton.circle size (double / String, 필수) + skeletonStyle width = height = size, variant 는 circular 고정
Skeleton.text skeletonStyle width / height 없음 (줄 수·높이는 skeletonStyle 로), variant 는 text 고정

표시 여부는 호출하는 쪽 조건으로#

Skeleton 에는 켜고 끄는 파라미터가 없습니다. 로딩 중인지는 호출하는 쪽이 이미 알고 있으므로, 그 조건으로 스켈레톤을 렌더하거나 렌더하지 않습니다. 화면 전체를 한 번에 스켈레톤으로 바꾸던 Flutter 의 asSkeleton(enabled:) 는 폐기 예고 상태입니다(0.156) — 서드파티 skeletonizer 가 위젯 트리를 칠하는 방식이라 색 · 맥동이 CoUI 토큰이 아니고, Web 대응이 없고, 아무것도 알리지 않습니다. 자리마다 Skeleton 을 두고 영역은 LoadingRegion 으로 감싸세요.

Column(
  children: [
    if (isLoading) Skeleton(width: 200, height: 20) else Text(title).bodyMedium,
  ],
)
div([
  if (isLoading) Skeleton(width: '200px', height: '20px') else Text(title).bodyMedium,
])

이전 버전의 enabled: 를 쓰던 코드를 옮길 때 — enabled: false 는 자리를 완전히 비우지 않았습니다(Flutter 는 크기 0 위젯, Web 은 숨긴 <div>). 그래서 형제를 세어 간격을 주는 레이아웃(Flutter Column.spacing, Web space-y-*)에서는 그 자리에 간격이 한 칸 남았습니다. 조건부 렌더는 자리를 남기지 않으므로, 옮긴 뒤 간격이 한 칸 줄어드는지 확인하세요.

빠른 오버라이드 (Chain)#

이미 만든 Skeleton 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius24처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius24 == CoreRadius.radius24) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class SkeletonChainExample extends StatefulWidget {
  const SkeletonChainExample({super.key});

  @override
  State<SkeletonChainExample> createState() => _SkeletonChainExampleState();
}

class _SkeletonChainExampleState extends State<SkeletonChainExample> {
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        const Skeleton(width: 200, height: 20).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(fromOpacity)까지 한 번에.
        const Skeleton(width: 200, height: 20).withStyle(
          const CoreSkeletonStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            fromOpacity: CoreOpacity.opacity20,
          ),
        ),
      ],
    );
  }
}
class SkeletonChainExample extends StatefulComponent {
  const SkeletonChainExample({super.key});

  @override
  State<SkeletonChainExample> createState() => _SkeletonChainExampleState();
}

class _SkeletonChainExampleState extends State<SkeletonChainExample> {
  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Skeleton(width: '200px', height: '20px').radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(fromOpacity)까지 한 번에.
        Skeleton(width: '200px', height: '20px').withStyle(
          const CoreSkeletonStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            fromOpacity: CoreOpacity.opacity20,
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

변형 (Variants)#

rectangular#

기본 직사각형 형태입니다.

Skeleton(width: 300, height: 150, variant: CoreSkeletonVariant.rectangular)

text#

텍스트 줄을 흉내 내는 여러 줄 형태입니다. 줄 수와 마지막 줄 너비는 skeletonStyle 로 지정합니다.

Skeleton.text(
  skeletonStyle: CoreSkeletonStyle(lines: 4, lastLineWidth: 0.6),
)

circular#

아바타나 원형 요소를 위한 형태입니다.

Skeleton.circle(size: 56)

동작 스펙 (Behavior)#

인터랙션#

  • Skeleton은 인터랙티브 요소가 아닙니다.

상태 전환#

  • 로딩 시작: 스켈레톤 표시 (pulse 애니메이션 시작)
  • 로딩 완료: 실제 콘텐츠로 교체 (AnimatedSwitcher 사용 권장)

애니메이션#

  • pulse 효과: 배경색 opacity 를 fromOpacity(0.7) → toOpacity(1.0) → fromOpacity 로 오가며 무한 반복합니다. duration(기본 2000ms)은 이 왕복 한 번의 길이이고, 쉼 → 정점과 정점 → 쉼 두 반쪽에 각각 curve(기본 CoreEasing.move)를 앞으로 적용합니다. 두 플랫폼이 같은 duration · curve 를 같은 뜻으로 읽으므로 같은 값을 넘기면 같은 속도로 맥동합니다
  • fromOpacity · toOpacity 는 채움색 자신의 알파에 곱해집니다. 투명한 채움 (.transparent)은 맥동 내내 투명하고, 불투명도 0.4 인 채움은 기본값에서 0.28 ↔ 0.4 사이를 오갑니다
  • Flutter 는 한 주기짜리 AnimationController 를 두 반쪽으로 나눠 alpha 를 보간하고, Web 은 0%,100%(쉼) · 50%(정점) 키프레임에 같은 주기와 곡선을 줍니다

사용 가이드라인 (Usage Guidelines)#

최종 콘텐츠의 자리·모양을 예약하는 로딩에는 호출자가 Skeleton 트리를 구성합니다. 완료 비율이 알려진 작업은 Progress, 모양 예약 없이 작업 중임을 알리려면 Loading을 사용합니다. 완료 후 보여줄 내용이 없으면 EmptyState, 콘텐츠를 유지한 채 한 작업이 실패하면 Alert를 선택합니다. Skeleton 자체는 데이터를 요청하거나 자식의 실제 값을 자동 대체하지 않습니다. surface-container-high 이상 회색 면 안에서는 Skeleton 대신 간격이나 면 차이로 구분합니다.

✅ Do#

실제 콘텐츠 레이아웃과 동일한 구조로 구성

// 실제 카드와 동일한 구조의 스켈레톤
Widget buildSkeletonCard() {
  return Card(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Skeleton(
          width: double.infinity,
          height: 180,
          skeletonStyle: CoreSkeletonStyle(
            borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
          ),
        ),
        Gap(size: CoreSpace.space12),
        Row(children: [
          Skeleton.circle(size: 40),
          Gap(size: CoreSpace.space8),
          Expanded(
            child: Skeleton.text(
              skeletonStyle: CoreSkeletonStyle(lines: 2),
            ),
          ),
        ]),
        Gap(size: CoreSpace.space8),
        Skeleton(width: 80, height: 14),
      ],
    ),
  );
}

스켈레톤이 실제 콘텐츠와 유사한 구조를 가지면 레이아웃 점프(CLS)를 방지하고 사용자의 기대를 정확히 설정합니다.


❌ Don't#

스켈레톤을 너무 오래 표시 금지

// ❌ 오류 발생 후에도 스켈레톤 유지
if (isLoading || hasError) {
  // 오류 상태에서도 스켈레톤
  return Skeleton.text(skeletonStyle: CoreSkeletonStyle(lines: 5));
}

오류가 발생했을 때 스켈레톤이 계속 표시되면 사용자가 영원히 기다리게 됩니다. 오류 상태는 EmptyState로 처리하세요.

✅ Do#

목록 스켈레톤은 3~5개 항목으로 제한

// 목록 스켈레톤: 실제 개수가 아닌 적당한 수 표시
if (isLoading) {
  return Column(
    spacing: CoreSpace.space12,
    children: List.generate(
      4,
      (index) => Skeleton.text(
        skeletonStyle: CoreSkeletonStyle(lines: 2),
      ),
    ),
  );
}

실제 데이터가 몇 개인지 모를 때 너무 많은 스켈레톤은 오히려 어색합니다. 3~5개가 자연스럽습니다.


❌ Don't#

Loading 스피너와 스켈레톤을 동시에 사용 금지

// ❌ 스켈레톤과 로딩 스피너 동시 표시
Stacks(
  children: [
    Skeleton.text(skeletonStyle: CoreSkeletonStyle(lines: 5)),
    Center(child: Loading()), // 중복
  ],
)

스켈레톤 자체가 로딩 상태를 나타냅니다. 스피너를 함께 표시하면 중복되어 혼란스럽습니다.

✅ Do#

실제 콘텐츠 레이아웃과 유사한 형태로 Skeleton을 구성하세요.

// 실제 카드와 동일한 구조의 Skeleton
Column(
  spacing: CoreSpace.space8,
  children: [
    Skeleton(width: double.infinity, height: 200),  // 이미지
    Skeleton(width: 200, height: 20),               // 제목
    Skeleton(width: double.infinity, height: 60),   // 설명
  ],
)

Skeleton 은 자리 표시자 하나를 그리는 leaf 요소입니다 — child 슬롯이 없으므로 실제 레이아웃은 여러 Skeleton 을 조합해서 만듭니다. Skeleton 레이아웃이 실제 콘텐츠와 유사할수록 사용자가 로딩 후 콘텐츠 변화에 덜 놀라고 자연스럽게 전환을 경험합니다.


❌ Don't#

로딩이 완료된 후에도 Skeleton을 표시하지 마세요.

// ❌ 데이터 로드 완료 후에도 Skeleton 유지
Widget build(BuildContext context) {
  return Column(children: [
    Skeleton(width: 200, height: 20),  // 항상 Skeleton 표시
    DataContent(data: loadedData),
  ]);
}

Skeleton은 데이터 로딩 중에만 표시해야 합니다. 로딩 완료 후에는 반드시 실제 콘텐츠로 교체하세요.

접근성 (Accessibility)#

역할#

  • Flutter — Semantics(liveRegion: true, label: …). ARIA 의 role 에 해당하는 값은 설정하지 않고 라이브 리전 표시와 이름만 붙입니다.
  • Web — 루트에 role="status" + aria-label, 그리고 그 안에 시각적으로 숨긴(sr-only) 문구 텍스트. progressbar 가 아닌 이유는 그 role 에 암묵적 라이브 리전 시맨틱이 없어 실제로는 아무것도 통보되지 않았기 때문입니다 — 같은 "아직 로딩 중" 을 말하는 Loading 컴포넌트와 같은 role 로 맞췄습니다.

키보드#

없음. 양 플랫폼 모두 포커스를 받지 않고 키 핸들러가 하나도 없어 Tab 은 스켈레톤을 그냥 지나갑니다. Web 생성자는 onClick / onKeyDown 같은 이벤트 파라미터를 아예 노출하지 않아 호출자가 키 조작을 붙일 수도 없습니다.

스크린 리더#

  • 이름의 출처 — 양 플랫폼 모두 활성 로케일의 로딩 문구(loadingLabel — 기본 Loading, 한국어 로딩 중)입니다. 컴파일타임 상수가 아니라 렌더 시점에 조회하므로 로케일을 바꾸면 양쪽이 같이 따라옵니다.
  • 통보됩니다 — Flutter 는 liveRegion: true, Web 은 role="status" 의 암묵적 aria-live="polite" 로 둘 다 라이브 리전입니다.
  • 스켈레톤 자체는 빈 상자라 읽을 콘텐츠가 없습니다. 리더가 듣는 것은 위 문구뿐입니다. Web 은 그 문구를 이름(aria-label)만이 아니라 텍스트로도 담습니다 — 라이브 리전은 이름이 아니라 내용을 읽기 때문입니다.
  • LoadingRegion 안에서는 알리지 않습니다. 영역이 한 번 알리고, 안쪽 스켈레톤은 role · aria-label · 문구 텍스트(Web), 라이브 리전(Flutter)을 내지 않습니다.

로딩이 끝나 호출부의 조건에서 스켈레톤이 빠지면 이 안내도 사라집니다.

포커스 관리#

없음. 오버레이가 아니어서 진입 시 포커스 이동이 없고, 이탈·트랩도 해당하지 않습니다. 로딩이 끝나 스켈레톤이 사라져도 잃을 포커스가 없습니다.

알려진 제약#

  • 스켈레톤 자신의 문구는 바꿀 수 없습니다. Flutter 에는 semanticLabel 파라미터가 없고, Web 은 attributes / classes / css 를 생성자에 노출하지 않는 데다 role 과 aria-label 을 componentAttributes 뒤에 병합해 언제나 컴포넌트 값이 이깁니다. "무엇이" 로딩 중인지는 LoadingRegion 의 semanticLabel 로 말합니다.
  • 단독 스켈레톤은 하나가 라이브 리전 하나입니다. 감싸지 않고 N 개를 놓으면 같은 문구를 가진 라이브 리전이 N 개 생깁니다. 한 영역을 채우는 스켈레톤들은 LoadingRegion 으로 감싸 영역 단위 한 번으로 알리세요.
  • "동작 줄이기" 를 켜면 맥동이 멈춥니다. 쉼 끝(fromOpacity) 채움으로 정지하고 로딩 알림은 그대로입니다. 표시 tier 이지만 멈춥니다 — 멈춘 회색 블록은 여전히 자리표시로 읽히기 때문입니다(멈춘 스피너와 다릅니다). 정책은 CoreReducedMotion.runsPlaceholderPulse 한 곳에 있습니다. Flutter 는 컨트롤러를 멈추고 쉼 끝을 그리며(주기는 0 으로 접지 않습니다), Web 은 prefers-reduced-motion 에서 animation-name 이 읽는 --coui-skeleton-pulse 변수를 쉼 끝을 붙드는 키프레임으로 바꿉니다.

전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
pulse 구현 한 주기 AnimationController 를 두 반쪽(쉼 → 정점 · 정점 → 쉼)으로 나눠 alpha 보간 0%,100% · 50% CSS 키프레임 — 같은 duration · curve · opacity 값
크기 타입 double? (logical pixels) String? (CSS 값, 예: '200px' / '100%')
HTML 속성 통과없음id
  • LoadingRegion: 스켈레톤 여러 개를 감싸 영역 단위로 한 번 알리는 래퍼
  • Loading: 콘텐츠 구조를 예측할 수 없는 로딩 상태 표시
  • EmptyState: 로딩 완료 후 데이터가 없는 상태 처리
  • Avatar: Skeleton.circle()을 아바타 로딩 자리 표시자로 사용

조합 예제#

// 완전한 로딩 → 빈 상태 → 콘텐츠 패턴
Widget buildUserList() {
  if (isLoading) {
    // 스켈레톤으로 로딩 표시
    return Column(
      spacing: CoreSpace.space12,
      children: List.generate(4, (_) => Row(children: [
        Skeleton.circle(size: 48),
        Gap(size: CoreSpace.space12),
        Expanded(
          child: Skeleton.text(
            skeletonStyle: CoreSkeletonStyle(lines: 2, lastLineWidth: 0.6),
          ),
        ),
      ])),
    );
  }

  if (users.isEmpty) {
    return EmptyState(
      icon: Icon(LucideIcons.users),
      title: Text('사용자가 없습니다'),
    );
  }

  return ListView.builder(
    itemCount: users.length,
    itemBuilder: (context, index) => UserListTile(user: users[index]),
  );
}