EmptyState
데이터가 없는 빈 상태를 안내하는 컴포넌트
목차
목록이나 콘텐츠 영역이 비어 있을 때 아이콘, 제목, 설명, 액션 버튼을 함께 표시하는 컴포넌트입니다.
기본 사용법 (Basic Usage)#
// 기본 빈 상태
EmptyState(
icon: Icon(LucideIcons.inbox),
title: Text('받은 메시지가 없습니다'),
description: Text('새로운 메시지가 도착하면 여기에 표시됩니다.'),
)
// 액션 버튼 포함
EmptyState(
icon: Icon(LucideIcons.folderOpen),
title: Text('파일이 없습니다'),
description: Text('파일을 업로드하거나 폴더를 만들어 시작하세요.'),
action: Button(
variant: CoreButtonVariant.primary,
onPressed: handleUpload,
child: Text('파일 업로드'),
),
)
// 검색 결과 없음
EmptyState(
icon: Icon(LucideIcons.searchX),
title: Text('검색 결과가 없습니다'),
description: Text('"Flutter"에 대한 결과를 찾을 수 없습니다.'),
)
// 기본 빈 상태
EmptyState(
icon: Icon(LucideIcons.inbox),
title: Text('받은 메시지가 없습니다'),
description: Text('새로운 메시지가 도착하면 여기에 표시됩니다.'),
)
// 액션 버튼 포함
EmptyState(
icon: Icon(LucideIcons.folderOpen),
title: Text('파일이 없습니다'),
description: Text('파일을 업로드하거나 폴더를 만들어 시작하세요.'),
action: Button(
variant: CoreButtonVariant.primary,
onPressed: handleUpload,
child: Text('파일 업로드'),
),
)
// 검색 결과 없음
EmptyState(
icon: Icon(LucideIcons.searchX),
title: Text('검색 결과가 없습니다'),
description: Text('다른 검색어로 다시 시도해 보세요.'),
action: Button(
variant: CoreButtonVariant.outline,
onPressed: handleResetSearch,
child: Text('검색 초기화'),
),
)
빠른 오버라이드 (Chain)#
이미 만든 EmptyState 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. 리디자인으로 테두리·radius·배경이 컴포넌트에서 통째로 빠져, 그 값을 가리키던 토큰-정확 getter(.radius4,
.primary 등)도 함께 사라졌습니다 — 지금은 withStyle 하나가 전체 오버라이드 진입점입니다.
class EmptyStateChainExample extends StatefulWidget {
const EmptyStateChainExample({super.key});
@override
State<EmptyStateChainExample> createState() => _EmptyStateChainExampleState();
}
class _EmptyStateChainExampleState extends State<EmptyStateChainExample> {
@override
Widget build(BuildContext context) {
return const EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).withStyle(
const CoreEmptyStateStyle(
minHeight: 200,
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
);
}
}
class EmptyStateChainExample extends StatefulComponent {
const EmptyStateChainExample({super.key});
@override
State<EmptyStateChainExample> createState() => _EmptyStateChainExampleState();
}
class _EmptyStateChainExampleState extends State<EmptyStateChainExample> {
@override
Component build(BuildContext context) {
return EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).withStyle(
const CoreEmptyStateStyle(
minHeight: 200,
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
);
}
}
Live Preview#
코드 보기
class EmptyStateDefaultExample extends StatefulComponent {
const EmptyStateDefaultExample({super.key});
@override
State<EmptyStateDefaultExample> createState() =>
_EmptyStateDefaultExampleState();
}
class _EmptyStateDefaultExampleState extends State<EmptyStateDefaultExample> {
@override
Component build(BuildContext context) {
return EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
);
}
}
class EmptyStateDefaultExample extends StatefulWidget {
const EmptyStateDefaultExample({super.key});
@override
State<EmptyStateDefaultExample> createState() =>
_EmptyStateDefaultExampleState();
}
class _EmptyStateDefaultExampleState extends State<EmptyStateDefaultExample> {
@override
Widget build(BuildContext context) {
return const EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 목록이나 테이블에 표시할 데이터가 없을 때
- 검색 결과가 없는 경우 사용자에게 안내할 때
- 네트워크 오류로 데이터를 불러오지 못했을 때 재시도 옵션을 제공할 때
- 사용자가 아직 콘텐츠를 생성하지 않은 초기 상태에서 시작을 유도할 때
대신 다른 컴포넌트를 사용하세요:
- Alert: 보여줄 내용이 남아 있고 그 옆에서 한 가지 실패만 알려야 할 때. EmptyState는 표시할 내용이 없는 영역 자체를 대신합니다
- Skeleton: 데이터가 아직 오지 않았고 최종 콘텐츠 형태의 대역이 필요할 때
- Loading: 콘텐츠 대역 없이 작업 진행 중임을 표시할 때
- Banner: 비어 있는 콘텐츠 자리를 대신하지 않고 상단 공지 스트립이 필요할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W?
는 선택 슬롯입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
title |
W |
필수 |
빈 상태 제목 (
normal
은 titleMedium,
compact
는 titleSmall,
large
는 titleLarge — 역할의 굵기)
|
variant |
CoreEmptyStateVariant |
CoreEmptyStateVariant.normal |
시각 밀도 (
normal
/
compact
/
large
).
normal
은 페이지 전체를 채우는 빈 상태라 240 높이를 열어 두고,
compact
는 패널 안에서 내용만큼만 차지하며,
large
는 넓은 첫 화면 자리에 쓰는 더 큰 아이콘 상자와 여백을 가집니다. 아이콘 상자는
normal
64 ·
compact
48 ·
large
72 의 원이고 아이콘은 28 · 24 · 32 입니다
|
tone |
CoreEmptyStateTone |
CoreEmptyStateTone.neutral |
왜 비었는지 (
neutral
= 아직 아무것도 없음 /
failure
= 뭔가 실패해서 없음 /
success
= 끝나서 보여줄 것이 없음) — 스크린 리더 통보 + 아이콘 상자 면과 아이콘 색을 함께 바꿉니다
|
icon | W? | null | 상단에 표시할 아이콘 위젯 |
showIcon |
bool |
true |
icon 이 non-null 일 때 실제로 그릴지. 유효 표시 여부는 showIcon && icon != null |
description |
W? |
null |
부가 설명 텍스트 |
showDescription |
bool |
true |
description
이 non-null 일 때 실제로 그릴지. 유효 표시 여부는
showDescription && description != null
|
action | W? | null | 하단에 표시할 액션 버튼 |
emptyStateStyle |
CoreEmptyStateStyle? |
null |
패딩 · 최소 높이 · 텍스트 최대 폭 · 간격 · 아이콘/타이포 오버라이드 |
EmptyState.compact(...)명명 생성자는 양쪽 플랫폼에 있습니다 (variant: CoreEmptyStateVariant.compact와 동일).
tone — 밀도와 직교하는 축#
variant 는 밀도(얼마나 좁은 자리인지)를, tone 은 비어 있는 이유를 말합니다. 둘을 한 enum 으로 합치면 "compact" 와 "failure" 가 상호배타가 되므로 축이 나뉘어 있습니다.
tone 은 두 가지를 동시에 바꿉니다 — 스크린 리더 통보(Flutter CoUISemantics(role: .alert)
/ Web role="alert", failure 에만)와 아이콘 색 · 아이콘 상자 면(neutral
= on-surface-variant 위 surface-container-high, failure = error
위 error-container, success = success 위 success-container). "주문이 아직 없습니다" 와 "주문을 불러오지 못했습니다" 는 아무것도 하지 않으면 화면에서 같은 모양의 상자이고 스크린 리더에게는 완전히 같은데, 그중 하나만 사용자가 알아야 할 소식입니다. 통보만 바꾸고 화면을 그대로 두면 그건 축의 절반이라, 눈으로 보는 쪽도 함께 갈립니다.
EmptyState(
tone: CoreEmptyStateTone.failure,
icon: Icon(LucideIcons.circleAlert),
title: Text('주문을 불러오지 못했습니다'),
description: Text('연결을 확인한 뒤 다시 시도해 주세요.'),
action: Button(
variant: CoreButtonVariant.outline,
onPressed: handleRetry,
child: Text('다시 시도'),
),
)
스타일 시스템 (Style System)#
CoreEmptyStateStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
padding |
CoreEdgeInsets? |
Inner padding around the empty-state content (pre-scaling). |
minHeight |
double? |
Minimum height of the empty-state stack (logical px, pre-scaling) — keeps a region from collapsing when its results vanish.
null
→ the variant's [CoreEmptyStateVariantStyle.minHeight].
|
maxTextWidth |
double? |
Maximum width of the title + description text block (logical px, pre-scaling). The stack itself fills whatever width its host gives it; only the text is capped so long labels wrap instead of stretching edge to edge. |
iconTitleGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the icon → title spacer. Forwarded straight to
Gap(gapStyle: …)
by the platform widgets. Per-instance override is merged on top of the variant default (see [defaultsByVariant]).
|
titleDescriptionGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the title → description spacer. Forwarded straight to
Gap(gapStyle: …)
by the platform widgets. Per-instance override is merged on top of the variant default (see [defaultsByVariant]).
|
descriptionActionGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the description (or title if description is hidden) → action spacer. Forwarded straight to
Gap(gapStyle: …)
by the platform widgets. Per-instance override is merged on top of the variant default (see [defaultsByVariant]).
|
iconStyle |
CoreIconStyle? |
Icon slot style (size + colour). No container any more — the icon paints directly at this size, ambient-themed onto the caller-supplied icon widget the same way
Button
's leading/trailing icon is.
null
size → the widget's variant-keyed entry in [defaultsByVariant];
null
colour → [defaultIconColor].
|
iconBoxSize |
double? |
Edge length of the circular icon box the icon sits in (logical px, pre-scaling).
null
→ the variant's [CoreEmptyStateVariantStyle.iconBoxSize].
|
iconBoxColor |
CoreColor? |
Fill of the icon box. null → the tone's entry in [defaultsByTone]. |
iconBoxBorderRadius |
CoreBorderRadius? |
Corner radius of the icon box.
null
→ [defaultIconBoxBorderRadius] (fully rounded — a circle).
|
titleTextStyle |
CoreTextStyle? |
Title text style override, merged on top of the variant's title role (
titleMedium
normal /
titleSmall
compact). The role sets the weight; a weight stated here replaces it.
|
descriptionTextStyle |
CoreTextStyle? |
Description text style override. |
CoreEmptyStateStyle 변형별 기본값 (CoreEmptyStateVariantStyle)#
| 필드 | normal | compact | large |
|---|---|---|---|
padding |
CoreEdgeInsets.all(CoreSpace.space32) | CoreEdgeInsets.all(CoreSpace.space24) | CoreEdgeInsets.symmetric(horizontal: CoreSpace.space32, verti… |
minHeight | layout240 | space0 | layout240 |
iconSize | space28 | space24 | space32 |
iconBoxSize | space64 | space48 | space72 |
iconTitleGapStyle |
CoreGapStyle(size: CoreSpace.space16) | CoreGapStyle(size: CoreSpace.space10) | CoreGapStyle(size: CoreSpace.space20) |
titleDescriptionGapStyle |
CoreGapStyle(size: CoreSpace.space8) | CoreGapStyle(size: CoreSpace.space10) | CoreGapStyle(size: CoreSpace.space8) |
descriptionActionGapStyle |
CoreGapStyle(size: CoreSpace.space24) | CoreGapStyle(size: CoreSpace.space10) | CoreGapStyle(size: CoreSpace.space24) |
titleTextStyle |
CoreTextStyle.token(CoreTextStyles.titleMedium) | CoreTextStyle.token(CoreTextStyles.titleSmall) | CoreTextStyle.token(CoreTextStyles.titleLarge) |
descriptionTextStyle |
— | — | CoreTextStyle.token(CoreTextStyles.bodyLarge) |
사용 시나리오#
목록이 비어 있는 경우#
if (items.isEmpty)
EmptyState(
icon: Icon(LucideIcons.listChecks),
title: Text('항목이 없습니다'),
description: Text('새 항목을 추가하여 시작해 보세요.'),
action: Button(
variant: CoreButtonVariant.primary,
onPressed: handleAdd,
child: Text('추가하기'),
),
)
검색 결과 없음#
EmptyState(
icon: Icon(LucideIcons.searchX),
title: Text('검색 결과가 없습니다'),
description: Text('다른 검색어로 다시 시도해 보세요.'),
)
오류 상태#
EmptyState(
tone: CoreEmptyStateTone.failure,
icon: Icon(LucideIcons.circleAlert),
title: Text('데이터를 불러오지 못했습니다'),
description: Text('네트워크 연결을 확인하고 다시 시도해 주세요.'),
action: Button(
variant: CoreButtonVariant.outline,
onPressed: handleRetry,
child: Text('다시 시도'),
),
)
동작 스펙 (Behavior)#
인터랙션#
- EmptyState 자체는 인터랙티브하지 않습니다.
action위젯에 전달된 버튼을 통해 사용자 액션을 제공합니다.
레이아웃#
- 아이콘, 제목, 설명, 액션 버튼이 수직으로 중앙 정렬
- 최소 높이·패딩·본문 최대 폭은 emptyStateStyle이 정합니다. 화면의 어느 영역을 대신할지와 최종 부모 크기는 호출부가 정합니다
애니메이션#
- 없음. 단, 빈 상태와 콘텐츠 사이 전환 시 부모에서
AnimatedSwitcher사용 권장 - 빈 데이터나 실패를 스스로 판정하지 않습니다. 부모가 렌더 여부와 tone을 고르며
action콜백도 자식이 소유합니다. 자체 비활성 플래그는 없습니다
사용 가이드라인 (Usage Guidelines)#
✅ Do#
상황에 맞는 구체적인 메시지와 아이콘 사용
// 검색 결과 없음 - 구체적인 메시지
EmptyState(
icon: Icon(LucideIcons.searchX),
title: Text('"$searchQuery"에 대한 결과가 없습니다'),
description: Text('철자를 확인하거나 다른 검색어를 시도해 보세요.'),
)
상황에 맞는 구체적인 메시지는 사용자가 다음에 무엇을 해야 하는지 명확히 이해하는 데 도움을 줍니다.
❌ Don't#
모든 빈 상태에 동일한 메시지 사용 금지
// ❌ 상황과 무관한 일반적인 메시지
EmptyState(
title: Text('데이터가 없습니다'), // 너무 모호함
description: Text('나중에 다시 시도하세요.'),
)
일반적인 메시지는 사용자에게 다음 단계를 안내하지 못합니다. 빈 이유와 해결 방법을 명확하게 전달하세요.
✅ Do#
액션 버튼으로 빈 상태 해소를 돕기
// 비어 있는 장바구니에서 쇼핑 유도
EmptyState(
icon: Icon(LucideIcons.shoppingCart),
title: Text('장바구니가 비어 있습니다'),
description: Text('마음에 드는 상품을 담아보세요.'),
action: Button(
variant: CoreButtonVariant.primary,
onPressed: handleBrowseProducts,
child: Text('쇼핑 시작하기'),
),
)
빈 상태에서 다음 액션을 바로 제공하면 사용자 이탈을 줄일 수 있습니다.
❌ Don't#
로딩 중에 EmptyState 표시 금지
// ❌ 데이터 로딩 중에 빈 상태 표시
if (items.isEmpty) // isLoading 체크 없음
EmptyState(title: Text('항목이 없습니다'))
로딩 중에 빈 상태가 먼저 표시되었다가 데이터가 나타나는 플래시(flash) 현상이 발생합니다. isLoading 상태를 먼저 체크하세요.
✅ Do#
빈 상태에서 사용자가 할 수 있는 액션을 제안하세요.
EmptyState(
icon: Icon(LucideIcons.circlePlus),
title: Text('프로젝트가 없습니다'),
description: Text('새 프로젝트를 만들어 시작하세요.'),
action: Button(
variant: CoreButtonVariant.primary,
onPressed: handleCreateProject,
child: Text('프로젝트 만들기'),
),
)
빈 상태는 사용자를 막힌 곳에 두는 것이 아니라 다음 단계로 안내하는 기회입니다. 명확한 CTA를 제공하세요.
❌ Don't#
단순히 '데이터 없음'만 표시하지 마세요.
// ❌ 설명도, 액션도 없는 빈 상태
EmptyState(
title: Text('데이터 없음'),
)
맥락 없는 '데이터 없음' 메시지는 사용자를 혼란스럽게 합니다. 왜 비어있는지 이유와 해결 방법을 함께 제공하세요.
접근성 (Accessibility)#
역할#
-
중립 톤(
tone: neutral) — 역할 없음. 두 플랫폼 모두 컨테이너에 role 이나Semantics를 붙이지 않습니다. -
실패 톤(
tone: failure) — Web 은 루트에role="alert", Flutter 는CoUISemantics(role: .alert, container: true)를 붙입니다. -
제목은 heading 이 아닙니다. Web 은 제목·설명 모두
<div>로 렌더링되어<h1>~<h6>도 heading 역할도 붙지 않으므로 문서 개요(outline)에 나타나지 않습니다. Flutter 도DefaultTextStyle로 감싼 텍스트일 뿐입니다. 이 빈 상태가 섹션 제목 역할을 해야 한다면 호출자가 heading 을 바깥에 두세요. -
Web 은 caller
attributes가 나중에 병합되므로EmptyState(attributes: {'role': 'status'})로 통보 강도를 완화하거나 다른 역할로 바꿀 수 있습니다. Flutter 에는 대응 진입점이 없습니다.
키보드#
EmptyState 자신은 포커스를 받지 않습니다 — 아래 동작은 action 슬롯에 넘긴
위젯(문서 예제는 모두 Button)이 제공하는 것입니다.
| 키 | 동작 |
|---|---|
Tab | action 슬롯의 포커스 가능한 요소로 이동 |
Enter / Space | 그 요소 활성화 (Button 기준) |
스크린 리더#
-
이름의 출처는
title·description슬롯의 텍스트입니다. 컴포넌트가 별도 라벨을 붙이지 않습니다. -
통보되는 것은
tone: failure뿐입니다 — Web 은role="alert"(assertive), Flutter 는liveRegion: true. 중립 톤은 통보하지 않습니다. -
Flutter 에는 통보 긴급도 축이 없습니다.
alert역할이 live region 하나로만 번역되어 polite/assertive 구분이 사라집니다 — 미구현이 아니라 플랫폼 capability 차이입니다.
포커스 관리#
- 진입 / 이탈 — 이동 없음. 오버레이가 아니라 자리를 채우는 표시이므로 나타나거나 사라질 때 포커스를 옮기지 않습니다.
- 트랩 — 없음.
Tab은 그대로 빠져나갑니다. - 목록이 비어 빈 상태로 교체되는 화면에서, 사라진 항목에 포커스가 있었다면 포커스를 받아주는 코드가 없습니다 — 호출자가 옮겨야 합니다.
알려진 제약#
-
tone은 아이콘 상자 면과 아이콘 색만 바꿉니다. 톤 기본값이iconBoxColor와iconStyle.color를 지정하는 것이 전부라,icon을 넘기지 않으면 아이콘 상자가 그려지지 않아 중립 톤과 화면상 차이가 전혀 없습니다. 아이콘이 있어도 OS 색 강제 모드에서는 시맨틱 색들이 같은 시스템 색으로 접혀 두 톤이 같아 보입니다. 통보 여부는 그대로 갈리므로 화면과 리더가 다른 이야기를 하게 됩니다. 대안: 실패 톤에 다른 아이콘을 넘기거나, 실패라는 사실을title/description문구에 직접 씁니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | EmptyState | EmptyState |
| 레이아웃 | Column + Center | Flexbox 수직 정렬 |
| 아이콘 슬롯 | 임의 Widget에 IconTheme 상속 | 임의 Component를 아이콘 래퍼에 배치 |
| 실패 통보 | alert 역할이 live region으로 매핑 | role=alert의 assertive 통보 |
관련 컴포넌트 (Related Components)#
조합 예제#
// 로딩 → 빈 상태 → 콘텐츠 전환 패턴
Widget buildContent() {
if (isLoading) {
return const Skeleton.text();
}
if (items.isEmpty) {
return EmptyState(
icon: Icon(LucideIcons.inbox),
title: Text('항목이 없습니다'),
description: Text('새 항목을 추가하여 시작해 보세요.'),
action: Button(
variant: CoreButtonVariant.primary,
onPressed: handleAdd,
child: Text('추가하기'),
),
);
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemTile(item: items[index]),
);
}