Skeleton | CoUI
LogoCoUI

Skeleton

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

Skeleton#

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

Live Preview#

사용 시기 (When to Use)#

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

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

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

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

기본 사용법 (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'),
  ]),
])

Props / Parameters#

Skeleton (Flutter) / Skeleton (Web)#

속성타입 (Flutter / Web)기본값설명
width double? / String? null 스켈레톤 너비
height double? / String? null 스켈레톤 높이
variant CoreSkeletonVariant CoreSkeletonVariant.defaultVariant (rectangular) 스켈레톤 형태
enabled bool true 스켈레톤 표시 여부 (false 면 아무것도 그리지 않음)
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? Pulse animation cycle duration override.
fromOpacity double? Pulse animation start opacity (0.0–1.0).
toOpacity double? Pulse animation end opacity (0.0–1.0).

CoreSkeletonStyle 변형별 기본값 (CoreSkeletonVariantStyle)#

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

팩토리 생성자#

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

빠른 오버라이드 (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 애니메이션 시작)
  • enabled: false: 아무것도 그리지 않고 애니메이션도 멈춤
  • 로딩 완료: 실제 콘텐츠로 교체 (AnimatedSwitcher 사용 권장)

애니메이션#

  • pulse 효과: 배경색 opacity 를 fromOpacity(0.5) ↔ toOpacity(1.0) 사이에서 왕복. 한 주기는 duration(기본 1000ms), easeInOut 커브로 무한 반복
  • Flutter 는 AnimationController + alpha 보간, Web 은 CSS pulse 애니메이션으로 같은 토큰 값을 사용합니다

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

✅ 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)#

키보드 인터랙션#

해당 없음. Skeleton은 인터랙티브 요소가 아닙니다.

스크린 리더#

  • Flutter: Semantics(liveRegion: true, label: <활성 로케일의 로딩 문구>) 로 "아직 로딩 중" 을 알립니다.
  • Web: 루트에 role="status" + aria-label="Loading" 이 자동으로 붙습니다 — 같은 live-region 통보를 내는 Loading 컴포넌트와 동일한 role 입니다 (role="progressbar" 는 암시적 live-region 시맨틱이 없어 실제로 아무것도 통보되지 않았습니다).
  • enabled: false 면 아무것도 렌더하지 않으므로 이 안내도 사라집니다 — 로딩이 끝났다는 뜻이므로 의도된 동작입니다.

터치 타겟#

해당 없음. 표시 전용 요소.

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

항목FlutterWeb
pulse 구현 AnimationController + alpha 보간 CSS 애니메이션 (같은 duration / opacity 토큰)
크기 타입 double? (logical pixels) String? (CSS 값, 예: '200px' / '100%')
HTML 속성 통과없음id
  • 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]),
  );
}