Skeleton | CoUI

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),
      ),
    ),
    SizedBox(height: 12),
    Skeleton.text(
      skeletonStyle: CoreSkeletonStyle(lines: 2),
    ),
    SizedBox(height: 8),
    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 rectangular 스켈레톤 형태
enabledbooltrue스켈레톤 표시 여부
skeletonStyle CoreSkeletonStyle? null chrome / dimensional / animation 슬롯

CoreSkeletonStyle 필드#

필드타입기본값설명
backgroundColor CoreColor? variant 기반 스켈레톤 배경색
borderRadius CoreBorderRadius? variant 기반 모서리 둥글기
lineSpacing double? CoreSpace.space8 줄 간격 (logical px) — text variant. native Column.spacing (Flutter) / row-gap (Web)
lastLineWidth double? 0.6 마지막 줄 너비 비율 (0.0 – 1.0)
lines int? 3 표시할 텍스트 줄 수 (text variant)
duration Duration? 1000ms pulse 애니메이션 주기
fromOpacity double? 0.5 pulse 시작 opacity
toOpacity double? 1.0 pulse 종료 opacity

Skeleton.circle 팩토리#

속성타입 (Flutter / Web)기본값설명
size double / String 필수 원형 지름 크기

변형 (Variants)#

rectangular#

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

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

text#

텍스트 줄을 흉내 내는 여러 줄 형태입니다.

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

circular#

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

Skeleton.circle(size: 56)

동작 스펙 (Behavior)#

인터랙션#

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

상태 전환#

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

애니메이션#

  • shimmer 효과: 왼쪽에서 오른쪽으로 흘러가는 빛나는 반짝임 1.5s 무한 반복
  • 색상: 베이스 색상 → 약간 밝은 색상 → 베이스 색상

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

✅ Do#

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

// 실제 카드와 동일한 구조의 스켈레톤
Widget buildSkeletonCard() {
  return CouiCard(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Skeleton(width: double.infinity, height: 180, borderRadius: 8),
        SizedBox(height: 12),
        Row(children: [
          Skeleton.circle(size: 40),
          SizedBox(width: 8),
          Expanded(child: Skeleton.text(lines: 2)),
        ]),
        SizedBox(height: 8),
        Skeleton(width: 80, height: 14),
      ],
    ),
  );
}

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


❌ Don't#

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

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

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

✅ Do#

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

// 목록 스켈레톤: 실제 개수가 아닌 적당한 수 표시
if (isLoading) {
  return Column(
    children: List.generate(4, (index) => Padding(
      padding: EdgeInsets.only(bottom: 12),
      child: SkeletonListItem(),
    )),
  );
}

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


❌ Don't#

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

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

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

✅ Do#

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

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

Skeleton 레이아웃이 실제 콘텐츠와 유사할수록 사용자가 로딩 후 콘텐츠 변화에 덜 놀라고 자연스럽게 전환을 경험합니다.


❌ Don't#

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

// ❌ 데이터 로드 완료 후에도 Skeleton 유지
Widget build(BuildContext context) {
  return Skeleton(  // 항상 Skeleton 표시
    child: DataContent(data: loadedData),
  );
}

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

접근성 (Accessibility)#

키보드 인터랙션#

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

스크린 리더#

  • Flutter: Semantics(label: '로딩 중', liveRegion: true)로 로딩 상태 알림
  • Web: role="progressbar", aria-label="콘텐츠 로딩 중" 자동 적용

터치 타겟#

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

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

항목FlutterWeb
클래스명SkeletonSkeleton
shimmer 구현 AnimatedContainer + pulse alpha CSS animate-pulse + Tailwind
팩토리 메서드 Skeleton.text(), Skeleton.circle() Skeleton.text(), Skeleton.circle()
크기 타입 double? (logical pixels) String? (CSS values)
  • Loading: 콘텐츠 구조를 예측할 수 없는 로딩 상태 표시
  • EmptyState: 로딩 완료 후 데이터가 없는 상태 처리
  • Avatar: Skeleton.circle()을 아바타 로딩 자리 표시자로 사용

조합 예제#

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

  if (users.isEmpty) {
    return CouiEmptyState(
      icon: Icon(Icons.people_outline, size: 48),
      title: '사용자가 없습니다',
    );
  }

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