EmptyState | CoUI

EmptyState

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

EmptyState#

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

Live Preview#

Web
No results found
Try adjusting your search or filters.
Flutter
Loading Flutter...
class EmptyStateDefaultExample extends StatefulComponent {
  const EmptyStateDefaultExample({super.key});

  @override
  State<EmptyStateDefaultExample> createState() =>
      _EmptyStateDefaultExampleState();
}

class _EmptyStateDefaultExampleState extends State<EmptyStateDefaultExample> {
  @override
  Component build(BuildContext context) {
    return CoEmptyState(
      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 CoEmptyState(
      title: Text('No results found'),
      description: Text('Try adjusting your search or filters.'),
    );
  }
}

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 목록이나 테이블에 표시할 데이터가 없을 때
  • 검색 결과가 없는 경우 사용자에게 안내할 때
  • 네트워크 오류로 데이터를 불러오지 못했을 때 재시도 옵션을 제공할 때
  • 사용자가 아직 콘텐츠를 생성하지 않은 초기 상태에서 시작을 유도할 때

대신 다른 컴포넌트를 사용하세요:

  • Skeleton: 데이터 로딩 중인 상태를 표시할 때 (빈 상태가 아님)
  • Loading: 단순히 작업 진행 중임을 표시할 때
  • Banner: 페이지 전체 범위의 시스템 메시지가 필요할 때

기본 사용법 (Basic Usage)#

// 기본 빈 상태
CoEmptyState(
  icon: Icon(Icons.inbox_outlined, size: 48),
  title: '받은 메시지가 없습니다',
  description: '새로운 메시지가 도착하면 여기에 표시됩니다.',
)

// 액션 버튼 포함
CoEmptyState(
  icon: Icon(Icons.folder_open_outlined, size: 48),
  title: '파일이 없습니다',
  description: '파일을 업로드하거나 폴더를 만들어 시작하세요.',
  action: Button.primary(
    onPressed: handleUpload,
    child: Text('파일 업로드'),
  ),
)

// 검색 결과 없음
CoEmptyState(
  icon: Icon(Icons.search_off, size: 48),
  title: '검색 결과가 없습니다',
  description: '"Flutter"에 대한 결과를 찾을 수 없습니다.',
)
// 기본 빈 상태
CoEmptyState(
  title: '받은 메시지가 없습니다',
  description: '새로운 메시지가 도착하면 여기에 표시됩니다.',
)

// 액션 버튼 포함
CoEmptyState(
  title: '파일이 없습니다',
  description: '파일을 업로드하거나 폴더를 만들어 시작하세요.',
  action: button(
    [Component.text('파일 업로드')],
    onClick: handleUpload,
    classes: 'btn btn-primary',
  ),
)

// 검색 결과 없음
CoEmptyState(
  title: '검색 결과가 없습니다',
  description: '다른 검색어로 다시 시도해 보세요.',
  action: button(
    [Component.text('검색 초기화')],
    onClick: handleResetSearch,
    classes: 'btn btn-outline',
  ),
)

Props / Parameters#

속성타입기본값설명
title Widget / Component 필수 빈 상태 제목
icon Widget? / Component? null 상단에 표시할 아이콘 위젯
description Widget? (Flutter) / String? (Web) null 부가 설명 텍스트
action Widget? / Component? null 하단에 표시할 액션 버튼
compact bool false 컴팩트 모드 사용 여부
padding EdgeInsetsGeometry? / double? null 콘텐츠 패딩
iconSize double? null 아이콘 컨테이너 크기
iconColor Color? / CoreColor? null 아이콘 색상
minHeightdouble?null최소 높이

사용 시나리오#

목록이 비어 있는 경우#

if (items.isEmpty)
  CoEmptyState(
    icon: Icon(Icons.list_alt_outlined, size: 48),
    title: '항목이 없습니다',
    description: '새 항목을 추가하여 시작해 보세요.',
    action: Button.primary(
      onPressed: handleAdd,
      child: Text('추가하기'),
    ),
  )

검색 결과 없음#

CoEmptyState(
  icon: Icon(Icons.search_off, size: 48),
  title: '검색 결과가 없습니다',
  description: '다른 검색어로 다시 시도해 보세요.',
)

오류 상태#

CoEmptyState(
  icon: Icon(Icons.error_outline, size: 48),
  title: '데이터를 불러오지 못했습니다',
  description: '네트워크 연결을 확인하고 다시 시도해 주세요.',
  action: Button.outline(
    onPressed: handleRetry,
    child: Text('다시 시도'),
  ),
)

동작 스펙 (Behavior)#

인터랙션#

  • EmptyState 자체는 인터랙티브하지 않습니다.
  • action 위젯에 전달된 버튼을 통해 사용자 액션을 제공합니다.

레이아웃#

  • 아이콘, 제목, 설명, 액션 버튼이 수직으로 중앙 정렬
  • 부모 위젯의 크기에 맞게 자동 확장 (Expanded 또는 flex 내에서 사용 권장)

애니메이션#

  • 없음. 단, 빈 상태와 콘텐츠 사이 전환 시 부모에서 AnimatedSwitcher 사용 권장

사용 가이드라인 (Usage Guidelines)#

✅ Do#

상황에 맞는 구체적인 메시지와 아이콘 사용

// 검색 결과 없음 - 구체적인 메시지
CoCoEmptyState(
  icon: Icon(Icons.search_off, size: 48),
  title: '"${searchQuery}"에 대한 결과가 없습니다',
  description: '철자를 확인하거나 다른 검색어를 시도해 보세요.',
)

상황에 맞는 구체적인 메시지는 사용자가 다음에 무엇을 해야 하는지 명확히 이해하는 데 도움을 줍니다.


❌ Don't#

모든 빈 상태에 동일한 메시지 사용 금지

// ❌ 상황과 무관한 일반적인 메시지
CoCoEmptyState(
  title: '데이터가 없습니다', // 너무 모호함
  description: '나중에 다시 시도하세요.',
)

일반적인 메시지는 사용자에게 다음 단계를 안내하지 못합니다. 빈 이유와 해결 방법을 명확하게 전달하세요.

✅ Do#

액션 버튼으로 빈 상태 해소를 돕기

// 비어 있는 장바구니에서 쇼핑 유도
CoCoEmptyState(
  icon: Icon(Icons.shopping_cart_outlined, size: 48),
  title: '장바구니가 비어 있습니다',
  description: '마음에 드는 상품을 담아보세요.',
  action: CouiButton.primary(
    onPressed: handleBrowseProducts,
    child: Text('쇼핑 시작하기'),
  ),
)

빈 상태에서 다음 액션을 바로 제공하면 사용자 이탈을 줄일 수 있습니다.


❌ Don't#

로딩 중에 EmptyState 표시 금지

// ❌ 데이터 로딩 중에 빈 상태 표시
if (items.isEmpty) // isLoading 체크 없음
  CoCoEmptyState(title: '항목이 없습니다')

로딩 중에 빈 상태가 먼저 표시되었다가 데이터가 나타나는 플래시(flash) 현상이 발생합니다. isLoading 상태를 먼저 체크하세요.

✅ Do#

빈 상태에서 사용자가 할 수 있는 액션을 제안하세요.

CoCoEmptyState(
  icon: Icon(Icons.add_circle_outline),
  title: '프로젝트가 없습니다',
  description: '새 프로젝트를 만들어 시작하세요.',
  action: CouiButton.primary(
    onPressed: handleCreateProject,
    child: Text('프로젝트 만들기'),
  ),
)

빈 상태는 사용자를 막힌 곳에 두는 것이 아니라 다음 단계로 안내하는 기회입니다. 명확한 CTA를 제공하세요.


❌ Don't#

단순히 '데이터 없음'만 표시하지 마세요.

// ❌ 설명도, 액션도 없는 빈 상태
CoCoEmptyState(
  title: '데이터 없음',
)

맥락 없는 '데이터 없음' 메시지는 사용자를 혼란스럽게 합니다. 왜 비어있는지 이유와 해결 방법을 함께 제공하세요.

접근성 (Accessibility)#

키보드 인터랙션#

동작
Tabaction 버튼으로 이동
Enter / Space액션 버튼 활성화

스크린 리더#

  • Flutter: 제목과 설명이 순서대로 읽히도록 Column 구조 유지
  • Web: 제목은 <h2> 또는 <h3> 수준으로 렌더링되어 문서 구조 반영

터치 타겟#

  • action 버튼은 최소 48x48dp

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명CoEmptyStateCoEmptyState
레이아웃Column + CenterFlexbox 수직 정렬
아이콘Flutter Icon 위젯SVG 또는 아이콘 폰트
  • Skeleton: 데이터 로딩 중 자리 표시자
  • Loading: 단순 로딩 스피너
  • Banner: 페이지 전체에 대한 오류 메시지

조합 예제#

// 로딩 → 빈 상태 → 콘텐츠 전환 패턴
Widget buildContent() {
  if (isLoading) {
    return CouiSkeleton.text(lines: 5);
  }

  if (items.isEmpty) {
    return CoCoEmptyState(
      icon: Icon(Icons.inbox_outlined, size: 48),
      title: '항목이 없습니다',
      description: '새 항목을 추가하여 시작해 보세요.',
      action: CouiButton.primary(
        onPressed: handleAdd,
        child: Text('추가하기'),
      ),
    );
  }

  return ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) => ItemTile(item: items[index]),
  );
}