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}', [
Skeleton(width: '200px', height: '20px'),
Skeleton(width: '150px', height: '20px'),
Skeleton(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: [
Skeleton(width: 200, height: 20),
Skeleton(width: 150, height: 20),
Skeleton(width: 180, height: 20),
],
);
}
}
class SkeletonCircleExample extends StatefulComponent {
const SkeletonCircleExample({super.key});
@override
State<SkeletonCircleExample> createState() => _SkeletonCircleExampleState();
}
class _SkeletonCircleExampleState extends State<SkeletonCircleExample> {
@override
Component build(BuildContext context) {
return Skeleton.circle(size: '48px');
}
}
class SkeletonCircleExample extends StatefulWidget {
const SkeletonCircleExample({super.key});
@override
State<SkeletonCircleExample> createState() => _SkeletonCircleExampleState();
}
class _SkeletonCircleExampleState extends State<SkeletonCircleExample> {
@override
Widget build(BuildContext context) {
return const Skeleton.circle(size: 48);
}
}
class SkeletonTextExample extends StatefulComponent {
const SkeletonTextExample({super.key});
@override
State<SkeletonTextExample> createState() => _SkeletonTextExampleState();
}
class _SkeletonTextExampleState extends State<SkeletonTextExample> {
@override
Component build(BuildContext context) {
return Skeleton.text(
skeletonStyle: const CoreSkeletonStyle(lines: 3),
);
}
}
class SkeletonTextExample extends StatefulWidget {
const SkeletonTextExample({super.key});
@override
State<SkeletonTextExample> createState() => _SkeletonTextExampleState();
}
class _SkeletonTextExampleState extends State<SkeletonTextExample> {
@override
Widget build(BuildContext context) {
return const Skeleton.text(
skeletonStyle: CoreSkeletonStyle(lines: 3),
);
}
}
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',
);
}
}
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,
),
),
],
);
}
}
사용 시기 (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)#
| 필드 | rectangular | circular | text |
|---|---|---|---|
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)#
| 항목 | Flutter | Web |
|---|---|---|
| pulse 구현 | AnimationController + alpha 보간 |
CSS 애니메이션 (같은 duration / opacity 토큰) |
| 크기 타입 | double? (logical pixels) |
String? (CSS 값, 예: '200px' / '100%') |
| HTML 속성 통과 | 없음 | id |
관련 컴포넌트 (Related Components)#
- 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]),
);
}