レビュー済み·難易度: 初級·更新: 2026-04-18

Tailwind CSS で空状態(EmptyState)UI を実装する

検索結果ゼロ・データ未登録・エラー後などの空状態に表示するコンポーネントを Tailwind CSS でスタイリングし、アイコン・メッセージ・アクションボタンを組み合わせたパターン集。

nextjsstylingui-componenttailwindcss

対応バージョン

nextjs 15react 19tailwindcss 4

前提環境

Tailwind CSS の基本クラスと React コンポーネントの基本を理解していること

概要

リスト画面でデータが 0 件のとき、何も表示しないより「なぜ空なのか」「次に何をすべきか」を伝える EmptyState コンポーネントを設置するとユーザー体験が向上する。検索結果ゼロ・データ未登録・エラー後の 3 シナリオに対応した汎用コンポーネントを Tailwind CSS で実装する。

インストール

# Tailwind CSS が既にセットアップ済みであれば追加インストールは不要
npm install tailwindcss @tailwindcss/postcss

実装

汎用 EmptyState コンポーネント

// components/EmptyState.tsx
import type { ReactNode } from "react";

type Props = {
  icon?: ReactNode;
  title: string;
  description?: string;
  action?: ReactNode;
};

export default function EmptyState({ icon, title, description, action }: Props) {
  return (
    <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
      {icon && (
        <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 text-gray-400">
          {icon}
        </div>
      )}
      <h3 className="mb-2 text-base font-semibold text-gray-800">{title}</h3>
      {description && (
        <p className="mb-6 max-w-sm text-sm leading-relaxed text-gray-500">{description}</p>
      )}
      {action && <div>{action}</div>}
    </div>
  );
}

シナリオ別使用例

// components/ItemList.tsx
import EmptyState from "./EmptyState";

type Item = { id: number; name: string };

type Props = {
  items: Item[];
  query: string;
  hasError: boolean;
  onAdd: () => void;
  onRetry: () => void;
  onClear: () => void;
};

// SVG アイコン(インライン)
function SearchIcon() {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
    </svg>
  );
}

function InboxIcon() {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7m16 0-2 6H6l-2-6m16 0H4" />
    </svg>
  );
}

function AlertIcon() {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
    </svg>
  );
}

export default function ItemList({ items, query, hasError, onAdd, onRetry, onClear }: Props) {
  // エラー状態
  if (hasError) {
    return (
      <EmptyState
        icon={<AlertIcon />}
        title="データの取得に失敗しました"
        description="通信エラーが発生しました。しばらく待ってから再試行してください。"
        action={
          <button
            onClick={onRetry}
            className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white hover:bg-red-700"
          >
            再試行
          </button>
        }
      />
    );
  }

  // 検索結果ゼロ
  if (items.length === 0 && query) {
    return (
      <EmptyState
        icon={<SearchIcon />}
        title={`"${query}" に一致するアイテムはありません`}
        description="キーワードを変えるか、フィルターを解除してみてください。"
        action={
          <button
            onClick={onClear}
            className="rounded-lg border border-gray-300 px-5 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
          >
            検索をクリア
          </button>
        }
      />
    );
  }

  // データ未登録
  if (items.length === 0) {
    return (
      <EmptyState
        icon={<InboxIcon />}
        title="まだアイテムがありません"
        description="最初のアイテムを追加して始めましょう。"
        action={
          <button
            onClick={onAdd}
            className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700"
          >
            アイテムを追加
          </button>
        }
      />
    );
  }

  return (
    <ul className="divide-y">
      {items.map((item) => (
        <li key={item.id} className="px-4 py-3 text-sm text-gray-800">
          {item.name}
        </li>
      ))}
    </ul>
  );
}

ページへの組み込み

// app/items/page.tsx
"use client";

import { useState } from "react";
import ItemList from "@/components/ItemList";

export default function ItemsPage() {
  const [items] = useState<{ id: number; name: string }[]>([]);
  const [query, setQuery] = useState("");
  const [hasError] = useState(false);

  return (
    <main className="mx-auto max-w-xl p-8">
      <div className="mb-4 flex gap-2">
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="検索…"
          className="flex-1 rounded border px-3 py-2 text-sm"
        />
      </div>
      <div className="rounded-lg border">
        <ItemList
          items={items.filter((i) => i.name.includes(query))}
          query={query}
          hasError={hasError}
          onAdd={() => alert("追加")}
          onRetry={() => alert("再試行")}
          onClear={() => setQuery("")}
        />
      </div>
    </main>
  );
}

ポイント

  • icon / title / description / action をすべて Props として受け取り、シナリオごとに差し替えられる汎用コンポーネントにする。コンポーネント内にシナリオ別分岐を書くとスコープが広がりすぎるため、呼び出し側で制御する
  • アイコンは h-16 w-16 rounded-full bg-gray-100 の円形コンテナに収める。エラー時は text-red-400 でアイコン色を変えて深刻度を視覚的に区別する
  • 検索結果ゼロのケースは「クリア」ボタン、未登録は「追加」ボタン、エラーは「再試行」ボタンと、状況に合ったアクションを提示する
  • max-w-sm で説明文の最大幅を制限し、行が長くなりすぎないようにする。leading-relaxed で可読性を確保する
  • SVG アイコンはインラインで定義する代わりに lucide-reactheroicons などのライブラリを使うと管理が楽になる

注意点

tailwind-animation はアニメーション効果。react-multi-filter は絞り込みフィルタ UI。これは検索結果ゼロ・未登録・エラー後の 3 シナリオ別 EmptyState コンポーネントに特化。アイコン・タイトル・説明文・アクションボタンの組み合わせパターンを示す。

関連サンプル