EmptyState | CoUI
LogoCoUI

EmptyState

데이터가 없는 빈 상태를 안내하는 컴포넌트

EmptyState#

목록이나 콘텐츠 영역이 비어 있을 때 아이콘, 제목, 설명, 액션 버튼을 함께 표시하는 컴포넌트입니다.

Live Preview#

사용 시기 (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)#

필드normalcompact
paddingspace32space16
minHeight300space200
maxDescriptionWidth320space240
iconContainerSizespace80space56
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)이 제공하는 것입니다.

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

항목FlutterWeb
클래스명EmptyStateEmptyState
레이아웃Column + CenterFlexbox 수직 정렬
아이콘Flutter Icon 위젯SVG 또는 아이콘 폰트
  • 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]),
  );
}