Skeleton#
데이터 로딩 중에 콘텐츠 레이아웃을 미리 표시하는 스켈레톤 컴포넌트입니다. 사용자에게 로딩 상태를 시각적으로 전달합니다.
Live Preview#
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) | 기본값 | 설명 |
|---|---|---|---|
lines | int | 3 | 표시할 텍스트 줄 수 |
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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | CoSkeleton | CoSkeleton |
| shimmer 구현 | AnimatedContainer + pulse alpha |
CSS animate-pulse + Tailwind |
| 팩토리 메서드 | CoSkeleton.text(), CoSkeleton.circle() |
CoSkeleton.text(), CoSkeleton.circle() |
| 크기 타입 | double? (logical pixels) |
String? (CSS values) |
관련 컴포넌트 (Related Components)#
- 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]),
);
}