Skeleton
콘텐츠 로딩 중 자리 표시자를 표시하는 스켈레톤 컴포넌트
목차
데이터 로딩 중에 콘텐츠 레이아웃을 미리 표시하는 스켈레톤 컴포넌트입니다. 사용자에게 로딩 상태를 시각적으로 전달합니다.
기본 사용법 (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'),
]),
])
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 Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
Skeleton(width: 200, height: 20),
Skeleton(width: 150, height: 20),
Skeleton(width: 180, height: 20),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- API 요청이나 비동기 데이터 로딩 중에 콘텐츠의 대략적인 형태를 미리 보여줄 때
- 목록 항목, 카드, 텍스트 블록 등 콘텐츠 레이아웃이 예측 가능할 때
- 로딩 중에 레이아웃 점프(layout shift)를 방지하고 싶을 때
대신 다른 컴포넌트를 사용하세요:
Loading: 콘텐츠 구조를 예측할 수 없거나 단순히 진행 중임만 표시할 때EmptyState: 데이터가 없는 상태를 안내할 때 (로딩이 끝난 후)
Props / Parameters#
Skeleton (Flutter) / Skeleton (Web)#
| 속성 | 타입 (Flutter / Web) | 기본값 | 설명 |
|---|---|---|---|
width |
double? / String? |
null |
스켈레톤 너비 |
height |
double? / String? |
null |
스켈레톤 높이 |
variant |
CoreSkeletonVariant |
CoreSkeletonVariant.defaultVariant (rectangular) |
스켈레톤 형태 |
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? |
Length of one full pulse — rest → peak → rest — on both platforms.
null
→ [defaultDuration]. One value, one meaning: Web runs it as the keyframe cycle and Flutter as one controller period split into the same two halves, so the same override pulses at the same speed everywhere.
|
curve |
CoreCubicBezier? |
Easing of each half of the pulse, as cubic-bezier control points — applied forward to rest → peak and again to peak → rest, the way a CSS timing function applies per keyframe interval.
null
→ [defaultCurve].
|
fromOpacity |
double? |
Pulse animation start opacity (0.0–1.0), multiplied into the fill's own alpha at the resting end — a transparent fill stays transparent. |
toOpacity |
double? |
Pulse animation end opacity (0.0–1.0), multiplied into the fill's own alpha at the peak. |
CoreSkeletonStyle 변형별 기본값 (CoreSkeletonVariantStyle)#
| 필드 | rectangular | circular | text |
|---|---|---|---|
borderRadius |
CoreBorderRadius.all(CoreRadius.radius8) | CoreBorderRadius.all(CoreRadius.radius9999) | CoreBorderRadius.all(CoreRadius.radius4) |
backgroundColor |
surfaceContainerHighest | surfaceContainerHighest | surfaceContainerHighest |
팩토리 생성자#
| 생성자 | 파라미터 | 설명 |
|---|---|---|
Skeleton.circle |
size (double / String, 필수) + skeletonStyle |
width = height = size, variant 는 circular 고정 |
Skeleton.text |
skeletonStyle |
width
/
height
없음 (줄 수·높이는
skeletonStyle
로), variant 는
text
고정
|
표시 여부는 호출하는 쪽 조건으로#
Skeleton 에는 켜고 끄는 파라미터가 없습니다. 로딩 중인지는 호출하는 쪽이 이미 알고
있으므로, 그 조건으로 스켈레톤을 렌더하거나 렌더하지 않습니다. 화면 전체를 한 번에
스켈레톤으로 바꾸던 Flutter 의 asSkeleton(enabled:) 는 폐기 예고 상태입니다(0.156) — 서드파티
skeletonizer 가 위젯 트리를 칠하는 방식이라 색 · 맥동이 CoUI 토큰이 아니고, Web 대응이 없고,
아무것도 알리지 않습니다. 자리마다 Skeleton 을 두고 영역은 LoadingRegion
으로 감싸세요.
Column(
children: [
if (isLoading) Skeleton(width: 200, height: 20) else Text(title).bodyMedium,
],
)
div([
if (isLoading) Skeleton(width: '200px', height: '20px') else Text(title).bodyMedium,
])
이전 버전의 enabled: 를 쓰던 코드를 옮길 때 — enabled: false 는 자리를 완전히
비우지 않았습니다(Flutter 는 크기 0 위젯, Web 은 숨긴 <div>). 그래서 형제를 세어
간격을 주는 레이아웃(Flutter Column.spacing, Web space-y-*)에서는 그 자리에 간격이
한 칸 남았습니다. 조건부 렌더는 자리를 남기지 않으므로, 옮긴 뒤 간격이 한 칸 줄어드는지
확인하세요.
빠른 오버라이드 (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 애니메이션 시작)
- 로딩 완료: 실제 콘텐츠로 교체 (
AnimatedSwitcher사용 권장)
애니메이션#
-
pulse 효과: 배경색 opacity 를
fromOpacity(0.7) →toOpacity(1.0) →fromOpacity로 오가며 무한 반복합니다.duration(기본 2000ms)은 이 왕복 한 번의 길이이고, 쉼 → 정점과 정점 → 쉼 두 반쪽에 각각curve(기본CoreEasing.move)를 앞으로 적용합니다. 두 플랫폼이 같은duration·curve를 같은 뜻으로 읽으므로 같은 값을 넘기면 같은 속도로 맥동합니다 -
fromOpacity·toOpacity는 채움색 자신의 알파에 곱해집니다. 투명한 채움 (.transparent)은 맥동 내내 투명하고, 불투명도 0.4 인 채움은 기본값에서 0.28 ↔ 0.4 사이를 오갑니다 -
Flutter 는 한 주기짜리
AnimationController를 두 반쪽으로 나눠 alpha 를 보간하고, Web 은0%,100%(쉼) ·50%(정점) 키프레임에 같은 주기와 곡선을 줍니다
사용 가이드라인 (Usage Guidelines)#
최종 콘텐츠의 자리·모양을 예약하는 로딩에는 호출자가 Skeleton 트리를 구성합니다. 완료 비율이 알려진 작업은 Progress, 모양 예약 없이 작업 중임을 알리려면
Loading을 사용합니다. 완료 후 보여줄 내용이 없으면 EmptyState, 콘텐츠를 유지한 채 한 작업이 실패하면
Alert를 선택합니다. Skeleton 자체는 데이터를 요청하거나 자식의 실제 값을 자동 대체하지 않습니다. surface-container-high
이상 회색 면 안에서는 Skeleton 대신 간격이나 면 차이로 구분합니다.
✅ 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)#
역할#
-
Flutter —
Semantics(liveRegion: true, label: …). ARIA 의 role 에 해당하는 값은 설정하지 않고 라이브 리전 표시와 이름만 붙입니다. -
Web — 루트에
role="status"+aria-label, 그리고 그 안에 시각적으로 숨긴(sr-only) 문구 텍스트.progressbar가 아닌 이유는 그 role 에 암묵적 라이브 리전 시맨틱이 없어 실제로는 아무것도 통보되지 않았기 때문입니다 — 같은 "아직 로딩 중" 을 말하는Loading컴포넌트와 같은 role 로 맞췄습니다.
키보드#
없음. 양 플랫폼 모두 포커스를 받지 않고 키 핸들러가 하나도 없어 Tab 은 스켈레톤을 그냥 지나갑니다. Web 생성자는 onClick
/ onKeyDown 같은 이벤트 파라미터를 아예 노출하지 않아 호출자가 키 조작을 붙일 수도 없습니다.
스크린 리더#
-
이름의 출처 — 양 플랫폼 모두 활성 로케일의 로딩 문구(
loadingLabel— 기본Loading, 한국어로딩 중)입니다. 컴파일타임 상수가 아니라 렌더 시점에 조회하므로 로케일을 바꾸면 양쪽이 같이 따라옵니다. -
통보됩니다 — Flutter 는
liveRegion: true, Web 은role="status"의 암묵적aria-live="polite"로 둘 다 라이브 리전입니다. -
스켈레톤 자체는 빈 상자라 읽을 콘텐츠가 없습니다. 리더가 듣는 것은 위 문구뿐입니다. Web 은 그 문구를 이름(
aria-label)만이 아니라 텍스트로도 담습니다 — 라이브 리전은 이름이 아니라 내용을 읽기 때문입니다. -
LoadingRegion 안에서는 알리지 않습니다. 영역이 한 번 알리고, 안쪽 스켈레톤은
role·aria-label· 문구 텍스트(Web), 라이브 리전(Flutter)을 내지 않습니다.
로딩이 끝나 호출부의 조건에서 스켈레톤이 빠지면 이 안내도 사라집니다.
포커스 관리#
없음. 오버레이가 아니어서 진입 시 포커스 이동이 없고, 이탈·트랩도 해당하지 않습니다. 로딩이 끝나 스켈레톤이 사라져도 잃을 포커스가 없습니다.
알려진 제약#
-
스켈레톤 자신의 문구는 바꿀 수 없습니다. Flutter 에는
semanticLabel파라미터가 없고, Web 은attributes/classes/css를 생성자에 노출하지 않는 데다role과aria-label을componentAttributes뒤에 병합해 언제나 컴포넌트 값이 이깁니다. "무엇이" 로딩 중인지는 LoadingRegion 의semanticLabel로 말합니다. -
단독 스켈레톤은 하나가 라이브 리전 하나입니다. 감싸지 않고 N 개를 놓으면 같은 문구를 가진 라이브 리전이 N 개 생깁니다. 한 영역을 채우는 스켈레톤들은
LoadingRegion으로 감싸 영역 단위 한 번으로 알리세요. -
"동작 줄이기" 를 켜면 맥동이 멈춥니다. 쉼 끝(
fromOpacity) 채움으로 정지하고 로딩 알림은 그대로입니다. 표시 tier 이지만 멈춥니다 — 멈춘 회색 블록은 여전히 자리표시로 읽히기 때문입니다(멈춘 스피너와 다릅니다). 정책은CoreReducedMotion.runsPlaceholderPulse한 곳에 있습니다. Flutter 는 컨트롤러를 멈추고 쉼 끝을 그리며(주기는 0 으로 접지 않습니다), Web 은prefers-reduced-motion에서animation-name이 읽는--coui-skeleton-pulse변수를 쉼 끝을 붙드는 키프레임으로 바꿉니다.
전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| pulse 구현 | 한 주기 AnimationController 를 두 반쪽(쉼 → 정점 · 정점 → 쉼)으로 나눠 alpha 보간 |
0%,100%
·
50%
CSS 키프레임 — 같은
duration
·
curve
· opacity 값
|
| 크기 타입 | double? (logical pixels) |
String? (CSS 값, 예: '200px' / '100%') |
| HTML 속성 통과 | 없음 | id |
관련 컴포넌트 (Related Components)#
- LoadingRegion: 스켈레톤 여러 개를 감싸 영역 단위로 한 번 알리는 래퍼
- 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]),
);
}