EmptyState | CoUI
본문으로 건너뛰기
LogoCoUI

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#

사용 시기 (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 = 끝나서 보여줄 것이 없음) — 스크린 리더 통보 + 아이콘 상자 면과 아이콘 색을 함께 바꿉니다
iconW?null상단에 표시할 아이콘 위젯
showIcon bool true icon 이 non-null 일 때 실제로 그릴지. 유효 표시 여부는 showIcon && icon != null
description W? null 부가 설명 텍스트
showDescription bool true description 이 non-null 일 때 실제로 그릴지. 유효 표시 여부는 showDescription && description != null
actionW?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)#

필드normalcompactlarge
padding CoreEdgeInsets.all(CoreSpace.space32) CoreEdgeInsets.all(CoreSpace.space24) CoreEdgeInsets.symmetric(horizontal: CoreSpace.space32, verti…
minHeightlayout240space0layout240
iconSizespace28space24space32
iconBoxSizespace64space48space72
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)이 제공하는 것입니다.

키동작
Tabaction 슬롯의 포커스 가능한 요소로 이동
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)#

항목FlutterWeb
클래스명EmptyStateEmptyState
레이아웃Column + CenterFlexbox 수직 정렬
아이콘 슬롯임의 Widget에 IconTheme 상속임의 Component를 아이콘 래퍼에 배치
실패 통보alert 역할이 live region으로 매핑role=alert의 assertive 통보
  • Skeleton: 데이터 로딩 중 자리 표시자
  • Loading: 단순 로딩 스피너
  • Banner: 페이지 전체에 대한 오류 메시지

조합 예제#

// 로딩 → 빈 상태 → 콘텐츠 전환 패턴
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]),
  );
}