Skeleton | CoUI

Skeleton

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

Skeleton#

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

Live Preview#

Web
Flutter
Loading Flutter...
class SkeletonDefaultExample extends StatefulComponent {
  const SkeletonDefaultExample({super.key});

  @override
  State<SkeletonDefaultExample> createState() => _SkeletonDefaultExampleState();
}

class _SkeletonDefaultExampleState extends State<SkeletonDefaultExample> {
  @override
  Component build(BuildContext context) {
    return div(classes: 'flex flex-col gap-${CoreSpace.scale.space8}', [
      CoSkeleton(width: '200px', height: '20px'),
      CoSkeleton(width: '150px', height: '20px'),
      CoSkeleton(width: '180px', height: '20px'),
    ]);
  }
}
class SkeletonDefaultExample extends StatefulWidget {
  const SkeletonDefaultExample({super.key});

  @override
  State<SkeletonDefaultExample> createState() => _SkeletonDefaultExampleState();
}

class _SkeletonDefaultExampleState extends State<SkeletonDefaultExample> {
  @override
  Widget build(BuildContext context) {
    return const Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: CoreSpace.space8,
      children: [
        CoSkeleton(width: 200, height: 20),
        CoSkeleton(width: 150, height: 20),
        CoSkeleton(width: 180, height: 20),
      ],
    );
  }
}

사용 시기 (When to Use)#

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

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

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

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

기본 사용법 (Basic Usage)#

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

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

// 텍스트 스켈레톤 (여러 줄)
CoSkeleton.text(lines: 3)

// 카드 스켈레톤 조합
Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    CoSkeleton(width: double.infinity, height: 200, borderRadius: 12),
    SizedBox(height: 12),
    CoSkeleton.text(lines: 2),
    SizedBox(height: 8),
    CoSkeleton(width: 100, height: 16),
  ],
)
// 기본 직사각형 스켈레톤 (CSS 크기값 사용)
CoSkeleton(width: '200px', height: '20px')

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

// 텍스트 스켈레톤 (여러 줄)
CoSkeleton.text(lines: 3)

// 카드 스켈레톤 조합
div([
  CoSkeleton(width: '100%', height: '200px', borderRadius: 'rounded-lg'),
  div([
    CoSkeleton(width: '70%', height: '20px'),
    CoSkeleton(width: '50%', height: '16px'),
  ]),
])

Props / Parameters#

CoSkeleton (Flutter) / CoSkeleton (Web)#

속성타입 (Flutter / Web)기본값설명
width double? / String? null 스켈레톤 너비
height double? / String? null 스켈레톤 높이
borderRadius double? / String? variant 기반 모서리 둥글기
variant CoreSkeletonVariant rectangular 스켈레톤 형태

CoSkeleton.text 팩토리#

속성타입 (Flutter / Web)기본값설명
linesint3표시할 텍스트 줄 수
lineSpacing double (Flutter only) 8.0 줄 간격
lastLineWidth double? / String? 0.6 / '60%' 마지막 줄 너비 비율

CoSkeleton.circle 팩토리#

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

변형 (Variants)#

rectangular#

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

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

text#

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

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

circular#

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

CoSkeleton.circle(size: 56)

동작 스펙 (Behavior)#

인터랙션#

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

상태 전환#

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

애니메이션#

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

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

✅ Do#

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

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

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


❌ Don't#

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

// ❌ 오류 발생 후에도 스켈레톤 유지
if (isLoading || hasError) {
  return CoSkeleton.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: [
    CoSkeleton.text(lines: 5),
    Center(child: CouiLoading()), // 중복
  ],
)

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

✅ Do#

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

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

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


❌ Don't#

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

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

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

접근성 (Accessibility)#

키보드 인터랙션#

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

스크린 리더#

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

터치 타겟#

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

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

항목FlutterWeb
클래스명CoSkeletonCoSkeleton
shimmer 구현 AnimatedContainer + pulse alpha CSS animate-pulse + Tailwind
팩토리 메서드 CoSkeleton.text(), CoSkeleton.circle() CoSkeleton.text(), CoSkeleton.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: [
          CoSkeleton.circle(size: 48),
          SizedBox(width: 12),
          Expanded(child: CoSkeleton.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]),
  );
}