EmptyState#
목록이나 콘텐츠 영역이 비어 있을 때 아이콘, 제목, 설명, 액션 버튼을 함께 표시하는 컴포넌트입니다.
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.'),
);
}
}
class EmptyStateCompactExample extends StatefulComponent {
const EmptyStateCompactExample({super.key});
@override
State<EmptyStateCompactExample> createState() =>
_EmptyStateCompactExampleState();
}
class _EmptyStateCompactExampleState extends State<EmptyStateCompactExample> {
@override
Component build(BuildContext context) {
return EmptyState(
variant: CoreEmptyStateVariant.compact,
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
);
}
}
class EmptyStateCompactExample extends StatefulWidget {
const EmptyStateCompactExample({super.key});
@override
State<EmptyStateCompactExample> createState() =>
_EmptyStateCompactExampleState();
}
class _EmptyStateCompactExampleState extends State<EmptyStateCompactExample> {
@override
Widget build(BuildContext context) {
return const EmptyState(
variant: CoreEmptyStateVariant.compact,
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
);
}
}
class EmptyStateFailureExample extends StatefulComponent {
const EmptyStateFailureExample({super.key});
@override
State<EmptyStateFailureExample> createState() => _EmptyStateFailureExampleState();
}
class _EmptyStateFailureExampleState extends State<EmptyStateFailureExample> {
@override
Component build(BuildContext context) {
return EmptyState(
tone: CoreEmptyStateTone.failure,
icon: Icon(LucideIcons.circleAlert),
title: Text('Could not load results'),
description: Text('Check your connection and try again.'),
action: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: Text('Retry'),
),
);
}
}
class EmptyStateFailureExample extends StatefulWidget {
const EmptyStateFailureExample({super.key});
@override
State<EmptyStateFailureExample> createState() => _EmptyStateFailureExampleState();
}
class _EmptyStateFailureExampleState extends State<EmptyStateFailureExample> {
@override
Widget build(BuildContext context) {
return EmptyState(
tone: CoreEmptyStateTone.failure,
icon: const Icon(LucideIcons.circleAlert),
title: const Text('Could not load results'),
description: const Text('Check your connection and try again.'),
action: Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: const Text('Retry'),
),
);
}
}
class EmptyStateChainExample extends StatefulComponent {
const EmptyStateChainExample({super.key});
@override
State<EmptyStateChainExample> createState() => _EmptyStateChainExampleState();
}
class _EmptyStateChainExampleState extends State<EmptyStateChainExample> {
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).withStyle(
const CoreEmptyStateStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
classes: 'flex flex-col items-start',
);
}
}
class EmptyStateChainExample extends StatefulWidget {
const EmptyStateChainExample({super.key});
@override
State<EmptyStateChainExample> createState() => _EmptyStateChainExampleState();
}
class _EmptyStateChainExampleState extends State<EmptyStateChainExample> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
const EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
const EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).withStyle(
const CoreEmptyStateStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 목록이나 테이블에 표시할 데이터가 없을 때
- 검색 결과가 없는 경우 사용자에게 안내할 때
- 네트워크 오류로 데이터를 불러오지 못했을 때 재시도 옵션을 제공할 때
- 사용자가 아직 콘텐츠를 생성하지 않은 초기 상태에서 시작을 유도할 때
대신 다른 컴포넌트를 사용하세요:
Skeleton: 데이터 로딩 중인 상태를 표시할 때 (빈 상태가 아님)Loading: 단순히 작업 진행 중임을 표시할 때Banner: 페이지 전체 범위의 시스템 메시지가 필요할 때
기본 사용법 (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 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 ==
CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class EmptyStateChainExample extends StatefulWidget {
const EmptyStateChainExample({super.key});
@override
State<EmptyStateChainExample> createState() => _EmptyStateChainExampleState();
}
class _EmptyStateChainExampleState extends State<EmptyStateChainExample> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
const EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
const EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).withStyle(
const CoreEmptyStateStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
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 div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
EmptyState(
title: Text('No results found'),
description: Text('Try adjusting your search or filters.'),
).withStyle(
const CoreEmptyStateStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
classes: 'flex flex-col items-start',
);
}
}
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
title |
Widget / Component |
필수 | 빈 상태 제목 |
variant |
CoreEmptyStateVariant |
CoreEmptyStateVariant.normal |
시각 밀도 (normal / compact) |
tone |
CoreEmptyStateTone |
CoreEmptyStateTone.neutral |
왜 비었는지 (neutral = 아직 아무것도 없음 / failure = 뭔가 실패해서 없음) |
icon |
Widget? / Component? |
null |
상단에 표시할 아이콘 위젯 |
description |
Widget? / Component? |
null |
부가 설명 텍스트 |
action |
Widget? / Component? |
null |
하단에 표시할 액션 버튼 |
emptyStateStyle |
CoreEmptyStateStyle? |
null |
패딩 · 색상 · 아이콘 크기 · 보더 등 모든 chrome 오버라이드 |
EmptyState.compact(...)명명 생성자는 양쪽 플랫폼에 있습니다 (variant: CoreEmptyStateVariant.compact와 동일).
tone — 밀도와 직교하는 축#
variant 는 밀도(얼마나 좁은 자리인지)를, tone 은 비어 있는 이유를 말합니다. 둘을 한 enum 으로 합치면 "compact" 와 "failure" 가 상호배타가 되므로 축이 나뉘어 있습니다.
tone: failure 는 두 가지를 바꿉니다 — 아이콘이 error 토큰 색으로 바뀌고(defaultsByTone), 스크린 리더에 실패로 통보됩니다(Flutter
CoUISemantics(role: .alert) / Web role="alert"). "주문이 아직 없습니다" 와 "주문을 불러오지 못했습니다" 는 화면에서 같은 텍스트 상자이고 스크린 리더에게는 완전히 같으므로, 그중 소식인 쪽만 통보합니다.
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 container (logical px, pre-scaling). |
maxDescriptionWidth |
double? |
Maximum width of the description text block (logical px, pre-scaling). |
borderColor |
CoreColor? |
Outer border stroke colour. |
backgroundColor |
CoreColor? |
Container background colour. No paired
default*
: the empty state is a bordered outline over whatever surface hosts it, so the fill is opt-in — Flutter leaves
BoxDecoration.color
null, Web emits no
background-color
. A default would tint every empty state, including those already inside a card or dialog. Callers opt in through
.surface
/
.surfaceContainer
.
Deliberately has no default* — absence is the design.
The empty state is a bordered outline over its host surface: Flutter leaves BoxDecoration.color null, Web's
if (merged.backgroundColor != null)
emits nothing. A default tints every empty state, including ones already inside a card/dialog. Callers opt in via the.surface /.surfaceContainer chain getters.
|
borderWidth |
double? |
Outer border stroke width (logical px). |
borderRadius |
CoreBorderRadius? |
Outer border radius (4-corner). |
iconContainerSize |
double? |
Diameter of the round icon container. |
iconContainerColor |
CoreColor? |
Background colour of the round icon container. |
iconContainerShape |
CoreEmptyStateIconShape? |
Shape of the icon container (
circle
or
square
).
circle
renders a perfect circle;
square
renders a rounded square using the design-system box radius.
|
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 null) → action spacer. Forwarded straight to
Gap(gapStyle: …)
by the platform widgets. Per-instance override is merged on top of the variant default (see [defaultsByVariant]).
|
iconColorAlpha |
double? |
Alpha applied to the default icon colour when no explicit colour is set in [iconStyle]. |
borderAlpha |
double? |
Alpha applied to the default border colour. |
descriptionAlpha |
double? |
Alpha applied to the default description colour. |
iconSizeRatio |
double? |
Ratio of inner icon size to icon container size (0–1). |
iconStyle |
CoreIconStyle? |
Icon slot style (size + colour). |
titleTextStyle |
CoreTextStyle? |
Title text style override. |
descriptionTextStyle |
CoreTextStyle? |
Description text style override. |
CoreEmptyStateStyle 변형별 기본값 (CoreEmptyStateVariantStyle)#
| 필드 | normal | compact |
|---|---|---|
padding | space32 | space16 |
minHeight | 300 | space200 |
maxDescriptionWidth | 320 | space240 |
iconContainerSize | space80 | space56 |
iconTitleGapStyle |
CoreGapStyle(size: CoreSpace.space16) | CoreGapStyle(size: CoreSpace.space12) |
titleDescriptionGapStyle |
CoreGapStyle(size: CoreSpace.space8) | CoreGapStyle(size: CoreSpace.space4) |
descriptionActionGapStyle |
CoreGapStyle(size: CoreSpace.space24) | CoreGapStyle(size: CoreSpace.space16) |
사용 시나리오#
목록이 비어 있는 경우#
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위젯에 전달된 버튼을 통해 사용자 액션을 제공합니다.
레이아웃#
- 아이콘, 제목, 설명, 액션 버튼이 수직으로 중앙 정렬
- 부모 위젯의 크기에 맞게 자동 확장 (
Expanded또는flex내에서 사용 권장)
애니메이션#
- 없음. 단, 빈 상태와 콘텐츠 사이 전환 시 부모에서
AnimatedSwitcher사용 권장
사용 가이드라인 (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)#
키보드 인터랙션#
EmptyState 자신은 포커스를 받지 않습니다 — 아래 동작은 action 슬롯에 넘긴
위젯(문서 예제는 모두 Button)이 제공하는 것입니다.
| 키 | 동작 |
|---|---|
Tab | action 슬롯의 포커스 가능한 요소로 이동 |
Enter / Space | 그 요소 활성화 (Button 기준) |
스크린 리더#
- Flutter: 제목과 설명이 순서대로 읽히도록
Column구조 유지 -
Web: 제목·설명 모두
<div>로 렌더링됩니다 — heading 요소(<h1>~<h6>)나 heading 역할은 붙지 않으므로 문서 개요(outline)에는 나타나지 않습니다. 이 빈 상태가 섹션 제목 역할을 해야 한다면 호출자가 heading 을 바깥에 두세요. -
스크린 리더에 통보되는 것은
tone: failure뿐입니다 (위tone절 참조) — Web 은 루트에role="alert", Flutter 는CoUISemantics(role: .alert). 중립 톤은 통보하지 않습니다.
터치 타겟#
-
action슬롯은 호출자가 넣은 위젯을 그대로 그립니다 —Button을 넣으면size에 따라 32~48 px 높이로 그려져 WCAG 2.2 AA 최소치(24)를 넘습니다.TouchTarget으로 감싸는 포인터-타겟 하한은 그보다 작게 그리는 컴포넌트(checkbox / radio / chip)에만 적용되며 여기에는 개입하지 않습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | EmptyState | EmptyState |
| 레이아웃 | Column + Center | Flexbox 수직 정렬 |
| 아이콘 | Flutter Icon 위젯 | SVG 또는 아이콘 폰트 |
관련 컴포넌트 (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]),
);
}