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

Storybook で EmptyState コンポーネントの story とバリエーションを管理する

EmptyState コンポーネントの検索ゼロ・未登録・エラーの 3 バリアントを Storybook の story として定義し、args / argTypes でプロパティを切り替えながら確認できる例。

nextjstestingui-componentstorybook

対応バージョン

nextjs 15react 19storybook 8

前提環境

Storybook の基本設定と CSF (Component Story Format) の書き方を理解していること

概要

tailwind-empty-state-ui で実装した EmptyState コンポーネントを Storybook に登録し、検索ゼロ・未登録・エラーの 3 バリアントを story として管理する。args / argTypes でプロパティをコントロールし、デザイナーや開発者がブラウザ上でバリエーションを確認できる環境を作る。

インストール

npx storybook@latest init
# または既存プロジェクトに追加
npm install --save-dev @storybook/nextjs @storybook/addon-essentials

実装

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>
  );
}

EmptyState の story ファイル

// components/EmptyState.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import EmptyState from "./EmptyState";

// サンプル用 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>
  );
}

const meta: Meta<typeof EmptyState> = {
  title: "UI/EmptyState",
  component: EmptyState,
  parameters: {
    layout: "centered",
    docs: {
      description: {
        component:
          "リスト画面でデータが 0 件のときに表示する汎用コンポーネント。検索ゼロ・未登録・エラーの 3 シナリオに対応。",
      },
    },
  },
  tags: ["autodocs"],
  argTypes: {
    title: { control: "text" },
    description: { control: "text" },
    icon: { control: false },
    action: { control: false },
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

// 検索結果ゼロ
export const SearchZeroResults: Story = {
  name: "検索結果ゼロ",
  args: {
    icon: <SearchIcon />,
    title: '"react hooks" に一致するアイテムはありません',
    description: "キーワードを変えるか、フィルターを解除してみてください。",
    action: (
      <button className="rounded-lg border border-gray-300 px-5 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
        検索をクリア
      </button>
    ),
  },
};

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

// エラー後
export const ErrorState: Story = {
  name: "エラー後",
  args: {
    icon: <AlertIcon />,
    title: "データの取得に失敗しました",
    description: "通信エラーが発生しました。しばらく待ってから再試行してください。",
    action: (
      <button className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white hover:bg-red-700">
        再試行
      </button>
    ),
  },
};

// アイコンなし
export const WithoutIcon: Story = {
  name: "アイコンなし",
  args: {
    title: "表示できる項目がありません",
    description: "別の条件でお試しください。",
  },
};

// 最小構成(title のみ)
export const TitleOnly: Story = {
  name: "タイトルのみ",
  args: {
    title: "データがありません",
  },
};

Storybook 設定(Tailwind CSS 対応)

// .storybook/main.ts
import type { StorybookConfig } from "@storybook/nextjs";

const config: StorybookConfig = {
  stories: ["../components/**/*.stories.@(ts|tsx)"],
  addons: [
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook/nextjs",
    options: {},
  },
};

export default config;
// .storybook/preview.ts
import type { Preview } from "@storybook/react";
import "../app/globals.css"; // Tailwind CSS を読み込む

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

ポイント

  • tags: ["autodocs"]meta に付けると Storybook が自動でドキュメントページを生成する。argTypes で各 Prop の説明とコントロール種類を定義するとドキュメントが充実する
  • iconaction は JSX を受け取る Props のため control: false に設定し、Controls パネルには表示しない。Story ごとに具体的な値を args で設定する
  • parameters.layout: "centered" でコンポーネントを画面中央に配置して確認しやすくする
  • Story 名は SearchZeroResults のようにキャメルケースで定義し、name で日本語の表示名を付ける。Storybook サイドバーに日本語で表示され、チーム内での共有がしやすくなる
  • @storybook/nextjs を使うと Next.js の Link / Image / useRouter などが story 内でも動作する。globals.csspreview.ts でインポートすると Tailwind CSS のスタイルが適用される

注意点

tailwind-empty-state-ui は EmptyState コンポーネントの実装パターン。jest-component-test は @testing-library/react でのコンポーネントテスト。これは EmptyState の story 定義・バリアント整理・args による Props 制御に特化。Storybook と design.md の整合確認を進める最初の題材として扱う。

関連サンプル