T-CREATOR

Tailwind CSS × Storybook でフロント実装の開発効率アップ

Tailwind CSS × Storybook でフロント実装の開発効率アップ

フロントエンド開発の現場では、デザインとの乖離、開発効率の低下、品質管理の難しさといった課題が常につきまといます。特に React や Next.js などのモダンなフレームワークを使用する際、Tailwind CSS でスタイリングを行っても、実装したコンポーネントの動作確認やデザインとの整合性チェックに多くの時間を費やしてしまうことがあります。

そこで注目を集めているのが、Storybook と Tailwind CSS を組み合わせた開発アプローチです。この組み合わせにより、コンポーネント単位での効率的な開発フローを構築でき、開発速度を大幅に向上させることができます。

本記事では、Storybook を活用した開発ワークフローの最適化手法について、環境構築から実践的な運用まで、具体的なコード例とともに詳しく解説いたします。従来の開発手法で感じていた課題を解決し、チーム全体の生産性向上を実現する方法をお伝えしましょう。

背景

コンポーネント駆動開発の重要性

現代のフロントエンド開発では、UI を再利用可能なコンポーネントとして構築するコンポーネント駆動開発(Component-Driven Development)が主流となっています。

この開発手法により、以下のようなメリットを得られます。

  • 保守性の向上: コンポーネント単位でロジックが分離され、修正時の影響範囲が限定的
  • 開発効率の向上: 一度作成したコンポーネントを複数箇所で再利用可能
  • 品質の安定: 単一のコンポーネントでテストや検証を完結できる

Tailwind CSS との親和性

Tailwind CSS のユーティリティファーストアプローチは、コンポーネント駆動開発と非常に相性が良い特徴を持っています。

typescript// Tailwind を使ったコンポーネント例
const Button = ({ variant, size, children }) => {
  const baseClasses =
    'font-semibold rounded-lg transition-colors duration-200';

  const variantClasses = {
    primary: 'bg-blue-600 hover:bg-blue-700 text-white',
    secondary:
      'bg-gray-200 hover:bg-gray-300 text-gray-900',
    danger: 'bg-red-600 hover:bg-red-700 text-white',
  };

  const sizeClasses = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-base',
    lg: 'px-6 py-3 text-lg',
  };

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`}
    >
      {children}
    </button>
  );
};

このように、Tailwind CSS では各コンポーネント内でスタイルが完結するため、外部の CSS ファイルに依存せずにコンポーネントを開発できます。

Storybook の役割

Storybook は、UI コンポーネントを独立した環境で開発・テスト・文書化できるツールです。実際のアプリケーション環境から切り離してコンポーネントを動作確認できるため、以下の課題を解決できます。

従来の開発フローの課題

#課題項目詳細影響度
1環境依存の複雑さ特定のページやデータに依存してコンポーネント確認
2デザイン検証の困難さ実装後にデザインとの乖離が発覚
3状態管理の複雑さ様々な状態を再現するための準備が煩雑
4他チームとの連携デザイナーや PM が実装を確認しにくい

課題

ページ単位開発の非効率性

従来のフロントエンド開発では、ページ全体を構築してから各コンポーネントの動作確認を行うことが一般的でした。この手法では、以下のような非効率が発生します。

開発時間の無駄

typescript// 従来のアプローチ:ページ全体での確認が必要
const UserProfile = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // API呼び出しやデータ取得処理
    fetchUserData().then((data) => {
      setUser(data);
      setLoading(false);
    });
  }, []);

  if (loading) return <LoadingSpinner />; // この状態を確認するのが困難

  return (
    <div className='max-w-4xl mx-auto p-6'>
      <UserHeader user={user} />
      <UserStats stats={user.stats} />
      <UserActions userId={user.id} />
    </div>
  );
};

この場合、LoadingSpinner コンポーネントの表示確認だけでも、API 呼び出しやデータ取得処理を待つ必要があります。

状態再現の困難さ

特定の状態やエラーケースを再現するために、多くの手順を踏む必要があります。

typescript// エラー状態の確認が困難な例
const PaymentForm = () => {
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async (formData) => {
    setSubmitting(true);
    try {
      await processPayment(formData);
    } catch (error) {
      // このエラー状態を意図的に再現するのが困難
      setErrors(error.validationErrors);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {errors.cardNumber && (
        <ErrorMessage message={errors.cardNumber} />
      )}
      {/* フォーム内容 */}
    </form>
  );
};

デザインとの乖離

実装完了後にデザインとの差異が発見されるケースが頻発します。

レスポンシブ対応の検証不足

typescript// デザインとの乖離が発生しやすい例
const ProductCard = ({ product }) => {
  return (
    <div className='bg-white rounded-lg shadow-md p-6'>
      {/* モバイルでの表示崩れに気づかない */}
      <img
        src={product.image}
        alt={product.name}
        className='w-full h-48 object-cover mb-4'
      />
      <h3 className='text-xl font-semibold mb-2'>
        {product.name}
      </h3>
      <p className='text-gray-600 mb-4'>
        {product.description}
      </p>
      <div className='flex justify-between items-center'>
        <span className='text-2xl font-bold text-blue-600'>
          ¥{product.price.toLocaleString()}
        </span>
        <button className='bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700'>
          カートに追加
        </button>
      </div>
    </div>
  );
};

テスト環境の複雑さ

UI コンポーネントのテストを行う際、アプリケーション全体の環境を構築する必要があり、テストの実行時間や設定の複雑さが問題となります。

テスト環境構築の煩雑さ

typescript// 複雑なテスト環境設定の例
describe('UserDashboard', () => {
  beforeEach(() => {
    // モックデータの準備
    mockAPI.setup();

    // 認証状態の設定
    mockAuth.login({
      id: '123',
      name: 'Test User',
      role: 'admin',
    });

    // ルーティングの設定
    mockRouter.setup('/dashboard');

    // 状態管理の初期化
    store.dispatch(initializeApp());
  });

  it('should render user information', () => {
    // 実際のテストコード
    render(<UserDashboard />);
    // ...
  });
});

解決策

これらの課題を解決するために、Storybook と Tailwind CSS を組み合わせた開発ワークフローを構築します。段階的に実装していくことで、確実に開発効率を向上させることができます。

Storybook 環境のセットアップと Tailwind 統合

まず、既存の React プロジェクトに Storybook を導入し、Tailwind CSS と統合する手順を詳しく解説します。

初期セットアップ

bash# Storybook の初期化
yarn dlx storybook@latest init

# Tailwind CSS 関連パッケージのインストール
yarn add -D @storybook/addon-styling autoprefixer

Storybook 設定ファイルの作成

typescript// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-styling',
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
  typescript: {
    check: false,
    reactDocgen: 'react-docgen-typescript',
    reactDocgenTypescriptOptions: {
      shouldExtractLiteralValuesFromEnum: true,
      propFilter: (prop) =>
        prop.parent
          ? !/node_modules/.test(prop.parent.fileName)
          : true,
    },
  },
};

export default config;

Tailwind CSS の統合設定

typescript// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/index.css'; // Tailwind CSS をインポート

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
    // ビューポートの設定
    viewport: {
      viewports: {
        mobile: {
          name: 'Mobile',
          styles: {
            width: '375px',
            height: '667px',
          },
        },
        tablet: {
          name: 'Tablet',
          styles: {
            width: '768px',
            height: '1024px',
          },
        },
        desktop: {
          name: 'Desktop',
          styles: {
            width: '1200px',
            height: '800px',
          },
        },
      },
    },
  },
};

export default preview;

PostCSS 設定の調整

javascript// .storybook/postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

コンポーネント単位での開発フロー確立

Storybook を活用することで、アプリケーション全体を起動せずにコンポーネント単位で開発を進められます。

基本的な Story の作成

typescript// src/components/Button/Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

// Storybook のメタデータ設定
const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: { type: 'select' },
      options: ['sm', 'md', 'lg'],
    },
    disabled: {
      control: 'boolean',
    },
  },
};

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

// 基本的な Story
export const Primary: Story = {
  args: {
    variant: 'primary',
    size: 'md',
    children: 'ボタン',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    size: 'md',
    children: 'ボタン',
  },
};

export const Danger: Story = {
  args: {
    variant: 'danger',
    size: 'md',
    children: '削除',
  },
};

// サイズバリエーション
export const Small: Story = {
  args: {
    variant: 'primary',
    size: 'sm',
    children: '小さなボタン',
  },
};

export const Large: Story = {
  args: {
    variant: 'primary',
    size: 'lg',
    children: '大きなボタン',
  },
};

// 状態バリエーション
export const Disabled: Story = {
  args: {
    variant: 'primary',
    size: 'md',
    children: '無効化ボタン',
    disabled: true,
  },
};

// 複数状態の一覧表示
export const AllVariants: Story = {
  render: () => (
    <div className='space-y-4'>
      <div className='space-x-2'>
        <Button variant='primary' size='sm'>
          Primary Small
        </Button>
        <Button variant='primary' size='md'>
          Primary Medium
        </Button>
        <Button variant='primary' size='lg'>
          Primary Large
        </Button>
      </div>
      <div className='space-x-2'>
        <Button variant='secondary' size='sm'>
          Secondary Small
        </Button>
        <Button variant='secondary' size='md'>
          Secondary Medium
        </Button>
        <Button variant='secondary' size='lg'>
          Secondary Large
        </Button>
      </div>
      <div className='space-x-2'>
        <Button variant='danger' size='sm'>
          Danger Small
        </Button>
        <Button variant='danger' size='md'>
          Danger Medium
        </Button>
        <Button variant='danger' size='lg'>
          Danger Large
        </Button>
      </div>
    </div>
  ),
};

対応する Button コンポーネント

typescript// src/components/Button/Button.tsx
import React from 'react';
import {
  cva,
  type VariantProps,
} from 'class-variance-authority';

// Tailwind クラスのバリエーション定義
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
  {
    variants: {
      variant: {
        primary: 'bg-blue-600 text-white hover:bg-blue-700',
        secondary:
          'bg-gray-200 text-gray-900 hover:bg-gray-300',
        danger: 'bg-red-600 text-white hover:bg-red-700',
      },
      size: {
        sm: 'h-9 px-3 text-sm',
        md: 'h-10 px-4 py-2',
        lg: 'h-11 px-8 text-lg',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
);

// Props の型定義
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  children: React.ReactNode;
}

// Button コンポーネント
export const Button = React.forwardRef<
  HTMLButtonElement,
  ButtonProps
>(({ className, variant, size, ...props }, ref) => {
  return (
    <button
      className={buttonVariants({
        variant,
        size,
        className,
      })}
      ref={ref}
      {...props}
    />
  );
});

Button.displayName = 'Button';

## Hot Reload を活用した高速な反復開発

StorybookHot Reload 機能により、コードの変更が即座に反映されるため、高速な反復開発が可能になります。

### 開発フローの最適化

````typescript
// src/components/Card/Card.tsx
import React from 'react';

interface CardProps {
  title: string;
  description: string;
  image?: string;
  badge?: string;
  onClick?: () => void;
}

export const Card: React.FC<CardProps> = ({
  title,
  description,
  image,
  badge,
  onClick,
}) => {
  return (
    <div
      className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300 cursor-pointer"
      onClick={onClick}
    >
      {image && (
        <div className="relative">
          <img
            src={image}
            alt={title}
            className="w-full h-48 object-cover"
          />
          {badge && (
            <span className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-semibold">
              {badge}
            </span>
          )}
        </div>
      )}
      <div className="p-6">
        <h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
        <p className="text-gray-600 line-clamp-3">{description}</p>
      </div>
    </div>
  );
};

リアルタイム確認可能な Story

typescript// src/components/Card/Card.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { Card } from './Card';

const meta: Meta<typeof Card> = {
  title: 'Components/Card',
  component: Card,
  parameters: {
    layout: 'padded',
  },
  tags: ['autodocs'],
};

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

export const Default: Story = {
  args: {
    title: 'サンプル記事',
    description:
      'これは記事の概要です。Tailwind CSS と Storybook を組み合わせることで、効率的な開発が可能になります。',
  },
};

export const WithImage: Story = {
  args: {
    title: 'Featured Article',
    description:
      'この記事では最新の技術トレンドについて詳しく解説しています。実践的な例を通じて学習できます。',
    image:
      'https://images.unsplash.com/photo-1555949963-aa79dcee981c?w=400&h=300&fit=crop',
  },
};

export const WithBadge: Story = {
  args: {
    title: 'NEW: React 18の新機能',
    description:
      'React 18で追加された新機能について、コード例とともに詳しく説明します。',
    image:
      'https://images.unsplash.com/photo-1633356122102-3fe601e05bd2?w=400&h=300&fit=crop',
    badge: 'NEW',
  },
};

// インタラクティブな Story
export const Interactive: Story = {
  args: {
    title: 'クリック可能なカード',
    description:
      'このカードをクリックすると動作を確認できます。',
    image:
      'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=400&h=300&fit=crop',
    onClick: () => alert('カードがクリックされました!'),
  },
};

// レスポンシブ確認用
export const ResponsiveGrid: Story = {
  render: () => (
    <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'>
      <Card
        title='レスポンシブ確認 1'
        description='画面サイズを変更して表示を確認してください。'
        image='https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=400&h=300&fit=crop'
      />
      <Card
        title='レスポンシブ確認 2'
        description='モバイル、タブレット、デスクトップでの表示を確認できます。'
        image='https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=400&h=300&fit=crop'
      />
      <Card
        title='レスポンシブ確認 3'
        description='Storybook のビューポート機能で簡単に確認できます。'
        image='https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?w=400&h=300&fit=crop'
      />
    </div>
  ),
};

Addon を活用したデザインシステム管理

Storybook の豊富な Addon を活用することで、デザインシステムの管理と品質保証を効率化できます。

必須 Addon の導入

bash# デザインシステム管理に必要な Addon をインストール
yarn add -D @storybook/addon-docs @storybook/addon-controls @storybook/addon-viewport @storybook/addon-backgrounds @storybook/addon-measure @storybook/addon-outline

Addon 設定の最適化

typescript// .storybook/main.ts(追加設定)
const config: StorybookConfig = {
  // ... 既存設定
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-styling',
    '@storybook/addon-docs',
    '@storybook/addon-controls',
    '@storybook/addon-viewport',
    '@storybook/addon-backgrounds',
    '@storybook/addon-measure',
    '@storybook/addon-outline',
  ],
  docs: {
    autodocs: 'tag',
    defaultName: 'Documentation',
  },
};

カスタム背景色とビューポートの設定

typescript// .storybook/preview.ts(拡張版)
const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
    // 背景色の設定
    backgrounds: {
      default: 'light',
      values: [
        {
          name: 'light',
          value: '#ffffff',
        },
        {
          name: 'dark',
          value: '#1a1a1a',
        },
        {
          name: 'gray',
          value: '#f5f5f5',
        },
      ],
    },
    // ビューポート設定
    viewport: {
      viewports: {
        mobile: {
          name: 'Mobile',
          styles: {
            width: '375px',
            height: '667px',
          },
        },
        tablet: {
          name: 'Tablet',
          styles: {
            width: '768px',
            height: '1024px',
          },
        },
        desktop: {
          name: 'Desktop',
          styles: {
            width: '1200px',
            height: '800px',
          },
        },
        wide: {
          name: 'Wide Screen',
          styles: {
            width: '1440px',
            height: '900px',
          },
        },
      },
    },
  },
};

Design Token の管理

typescript// src/design-tokens/colors.ts
export const colors = {
  primary: {
    50: '#eff6ff',
    100: '#dbeafe',
    500: '#3b82f6',
    600: '#2563eb',
    700: '#1d4ed8',
    900: '#1e3a8a',
  },
  gray: {
    50: '#f9fafb',
    100: '#f3f4f6',
    500: '#6b7280',
    600: '#4b5563',
    900: '#111827',
  },
  success: {
    50: '#ecfdf5',
    500: '#10b981',
    600: '#059669',
  },
  warning: {
    50: '#fffbeb',
    500: '#f59e0b',
    600: '#d97706',
  },
  danger: {
    50: '#fef2f2',
    500: '#ef4444',
    600: '#dc2626',
  },
} as const;

// Storybook でのカラーパレット表示用
export const ColorPalette = () => {
  return (
    <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'>
      {Object.entries(colors).map(([colorName, shades]) => (
        <div key={colorName} className='space-y-2'>
          <h3 className='text-lg font-semibold capitalize'>
            {colorName}
          </h3>
          <div className='space-y-1'>
            {Object.entries(shades).map(
              ([shade, value]) => (
                <div
                  key={shade}
                  className='flex items-center space-x-3'
                >
                  <div
                    className='w-12 h-12 rounded border'
                    style={{ backgroundColor: value }}
                  />
                  <div className='flex-1'>
                    <div className='font-mono text-sm'>
                      {colorName}-{shade}
                    </div>
                    <div className='text-gray-500 text-xs'>
                      {value}
                    </div>
                  </div>
                </div>
              )
            )}
          </div>
        </div>
      ))}
    </div>
  );
};

具体例

実際の開発工程を通じて、Button コンポーネントから Form コンポーネントまでの段階的な実装例をご紹介します。

Button コンポーネントの開発工程

Step 1: 基本構造の実装

typescript// 1. 最小限の実装から開始
export const Button: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  return (
    <button className='px-4 py-2 bg-blue-600 text-white rounded'>
      {children}
    </button>
  );
};

Step 2: Props の拡張

typescript// 2. バリエーションの追加
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
}

export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'md',
  disabled = false,
  children,
  onClick,
}) => {
  const baseClasses =
    'font-medium rounded transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';

  const variantClasses = {
    primary:
      'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500',
    secondary:
      'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500',
    danger:
      'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
  };

  const sizeClasses = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-base',
    lg: 'px-6 py-3 text-lg',
  };

  const disabledClasses = disabled
    ? 'opacity-50 cursor-not-allowed'
    : '';

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${disabledClasses}`}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

Step 3: Story の段階的拡張

typescript// Button.stories.ts の段階的な拡張
export const Development: Story = {
  render: () => {
    const [count, setCount] = React.useState(0);

    return (
      <div className='space-y-4'>
        <h2 className='text-xl font-bold'>
          開発中の動作確認
        </h2>
        <div className='space-y-2'>
          <p>クリック回数: {count}</p>
          <div className='space-x-2'>
            <Button onClick={() => setCount(count + 1)}>
              カウントアップ
            </Button>
            <Button
              variant='secondary'
              onClick={() => setCount(0)}
            >
              リセット
            </Button>
            <Button
              variant='danger'
              onClick={() => setCount(count - 1)}
            >
              カウントダウン
            </Button>
          </div>
        </div>
      </div>
    );
  },
};

Form コンポーネントの段階的実装

Step 1: Input コンポーネントの作成

typescript// src/components/Input/Input.tsx
interface InputProps
  extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
  helpText?: string;
}

export const Input: React.FC<InputProps> = ({
  label,
  error,
  helpText,
  className = '',
  ...props
}) => {
  const inputClasses = `
    w-full px-3 py-2 border rounded-md shadow-sm
    focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
    ${error ? 'border-red-500' : 'border-gray-300'}
    ${className}
  `;

  return (
    <div className='space-y-1'>
      {label && (
        <label className='block text-sm font-medium text-gray-700'>
          {label}
        </label>
      )}
      <input className={inputClasses} {...props} />
      {error && (
        <p className='text-sm text-red-600'>{error}</p>
      )}
      {helpText && !error && (
        <p className='text-sm text-gray-500'>{helpText}</p>
      )}
    </div>
  );
};

Step 2: Input の Story 作成

typescript// src/components/Input/Input.stories.ts
export const Default: Story = {
  args: {
    label: 'メールアドレス',
    placeholder: 'your@example.com',
  },
};

export const WithError: Story = {
  args: {
    label: 'パスワード',
    type: 'password',
    error: 'パスワードは8文字以上で入力してください',
    value: '123',
  },
};

export const WithHelpText: Story = {
  args: {
    label: 'ユーザー名',
    helpText: '3-20文字の英数字で入力してください',
    placeholder: 'username',
  },
};

Step 3: Form コンポーネントの組み合わせ

typescript// src/components/Form/ContactForm.tsx
import { useState } from 'react';
import { Input } from '../Input/Input';
import { Button } from '../Button/Button';

interface FormData {
  name: string;
  email: string;
  message: string;
}

interface ContactFormProps {
  onSubmit?: (data: FormData) => void;
  loading?: boolean;
}

export const ContactForm: React.FC<ContactFormProps> = ({
  onSubmit,
  loading = false,
}) => {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    message: '',
  });

  const [errors, setErrors] = useState<Partial<FormData>>(
    {}
  );

  const validateForm = (): boolean => {
    const newErrors: Partial<FormData> = {};

    if (!formData.name.trim()) {
      newErrors.name = 'お名前は必須です';
    }

    if (!formData.email.trim()) {
      newErrors.email = 'メールアドレスは必須です';
    } else if (
      !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)
    ) {
      newErrors.email =
        '有効なメールアドレスを入力してください';
    }

    if (!formData.message.trim()) {
      newErrors.message = 'メッセージは必須です';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    if (validateForm()) {
      onSubmit?.(formData);
    }
  };

  const handleInputChange =
    (field: keyof FormData) =>
    (
      e: React.ChangeEvent<
        HTMLInputElement | HTMLTextAreaElement
      >
    ) => {
      setFormData({ ...formData, [field]: e.target.value });
      // エラーをクリア
      if (errors[field]) {
        setErrors({ ...errors, [field]: undefined });
      }
    };

  return (
    <form
      onSubmit={handleSubmit}
      className='space-y-6 max-w-md mx-auto'
    >
      <div>
        <Input
          label='お名前'
          value={formData.name}
          onChange={handleInputChange('name')}
          error={errors.name}
          placeholder='山田 太郎'
        />
      </div>

      <div>
        <Input
          label='メールアドレス'
          type='email'
          value={formData.email}
          onChange={handleInputChange('email')}
          error={errors.email}
          placeholder='yamada@example.com'
        />
      </div>

      <div className='space-y-1'>
        <label className='block text-sm font-medium text-gray-700'>
          メッセージ
        </label>
        <textarea
          className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
            errors.message
              ? 'border-red-500'
              : 'border-gray-300'
          }`}
          rows={4}
          value={formData.message}
          onChange={handleInputChange('message')}
          placeholder='お問い合わせ内容をご記入ください'
        />
        {errors.message && (
          <p className='text-sm text-red-600'>
            {errors.message}
          </p>
        )}
      </div>

      <Button
        type='submit'
        disabled={loading}
        className='w-full'
      >
        {loading ? '送信中...' : '送信する'}
      </Button>
    </form>
  );
};

Step 4: Form の Story でユーザーフローを確認

typescript// src/components/Form/ContactForm.stories.ts
export const Default: Story = {
  args: {
    onSubmit: (data) => {
      console.log('フォーム送信:', data);
      alert(
        `お問い合わせを受け付けました。\n名前: ${data.name}\nメール: ${data.email}`
      );
    },
  },
};

export const Loading: Story = {
  args: {
    loading: true,
    onSubmit: (data) => console.log('送信中...', data),
  },
};

export const WithPrefilledData: Story = {
  render: () => {
    // 事前入力済みの状態をテスト
    return (
      <ContactForm
        onSubmit={(data) =>
          console.log('事前入力データ:', data)
        }
      />
    );
  },
};

// エラー状態のテスト
export const ValidationTest: Story = {
  render: () => {
    return (
      <div className='space-y-8'>
        <div>
          <h3 className='text-lg font-semibold mb-4'>
            バリデーションテスト
          </h3>
          <p className='text-gray-600 mb-4'>
            何も入力せずに「送信する」ボタンをクリックしてエラー表示を確認してください。
          </p>
          <ContactForm
            onSubmit={(data) =>
              console.log('バリデーション通過:', data)
            }
          />
        </div>
      </div>
    );
  },
};

開発フローの最適化事例

実際の開発現場での活用例をご紹介します。

デザイナーとの連携改善

typescript// デザイナー確認用の専用 Story
export const DesignReview: Story = {
  render: () => (
    <div className='space-y-8 p-8'>
      <section>
        <h2 className='text-2xl font-bold mb-4'>
          デザイン確認用
        </h2>
        <p className='text-gray-600 mb-6'>
          各コンポーネントの最終的な見た目を確認してください。
        </p>
      </section>

      <section>
        <h3 className='text-lg font-semibold mb-4'>
          ボタンバリエーション
        </h3>
        <div className='grid grid-cols-3 gap-4'>
          <div className='space-y-2'>
            <Button variant='primary' size='sm'>
              Primary Small
            </Button>
            <Button variant='primary' size='md'>
              Primary Medium
            </Button>
            <Button variant='primary' size='lg'>
              Primary Large
            </Button>
          </div>
          <div className='space-y-2'>
            <Button variant='secondary' size='sm'>
              Secondary Small
            </Button>
            <Button variant='secondary' size='md'>
              Secondary Medium
            </Button>
            <Button variant='secondary' size='lg'>
              Secondary Large
            </Button>
          </div>
          <div className='space-y-2'>
            <Button variant='danger' size='sm'>
              Danger Small
            </Button>
            <Button variant='danger' size='md'>
              Danger Medium
            </Button>
            <Button variant='danger' size='lg'>
              Danger Large
            </Button>
          </div>
        </div>
      </section>

      <section>
        <h3 className='text-lg font-semibold mb-4'>
          フォーム要素
        </h3>
        <div className='max-w-md'>
          <ContactForm />
        </div>
      </section>
    </div>
  ),
};

まとめ

開発効率向上の定量的効果

Storybook と Tailwind CSS を組み合わせた開発フローにより、以下のような効果を実現できます。

パフォーマンス改善指標

#改善項目従来手法Storybook 活用後改善率
1コンポーネント動作確認時間5-10 分30 秒-1 分80-90%短縮
2デザイン確認・修正サイクル1-2 日数時間75%短縮
3状態再現にかかる時間10-30 分即座95%短縮
4レスポンシブ確認時間15-30 分1-2 分90%短縮

開発品質の向上

  1. 一貫性の向上: コンポーネント単位での開発により、デザインシステムの一貫性を保ちやすくなります
  2. バグの早期発見: 独立した環境でのテストにより、バグを早期に発見・修正できます
  3. ドキュメント化の自動化: Story が生きたドキュメントとして機能します

ベストプラクティス

1. Story の命名規則

typescript// 推奨: 用途が明確な命名
export const Default: Story = {
  /* ... */
};
export const WithIcon: Story = {
  /* ... */
};
export const LoadingState: Story = {
  /* ... */
};
export const ErrorState: Story = {
  /* ... */
};

// 非推奨: 抽象的な命名
export const Story1: Story = {
  /* ... */
};
export const Test: Story = {
  /* ... */
};

2. Args の活用

typescript// コントロール可能な Props を適切に設定
const meta: Meta<typeof Button> = {
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'danger'],
      description: 'ボタンの見た目を変更します',
    },
    disabled: {
      control: 'boolean',
      description: 'ボタンを無効化します',
    },
    children: {
      control: 'text',
      description: 'ボタンに表示するテキスト',
    },
  },
};

3. ディレクトリ構成

csssrc/
  components/
    Button/
      Button.tsx
      Button.stories.ts
      Button.test.tsx
      index.ts
    Input/
      Input.tsx
      Input.stories.ts
      Input.test.tsx
      index.ts
  design-tokens/
    colors.ts
    typography.ts
    spacing.ts

4. チーム運用のルール

  1. Story の必須化: 新規コンポーネント作成時は必ず Story も作成
  2. 定期的なレビュー: デザイナー・PM を含めた定期的な Story レビュー
  3. ドキュメンテーション: Args や Props の説明を必ず記載
  4. Visual Testing: 重要なコンポーネントは Visual Regression Testing を実装

Storybook と Tailwind CSS を組み合わせることで、従来の開発フローでは実現できなかった効率性と品質を両立できます。初期導入のコストはかかりますが、長期的には大幅な開発効率向上とコード品質の向上を実現できるでしょう。

ぜひ皆様のプロジェクトでも、段階的に導入を検討してみてください。

関連リンク