拡張性に優れたアーキテクチャ:Storybook、Tailwind、CVAによるデザインシステムの構築

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

実際にスケールするアーキテクチャ

フロントエンドプロジェクトは、ロジックの絡まりやスタイルの重複という壁にぶつかるまでは、通常スムーズに拡張されます。私は、50以上のユニークなコンポーネントを持つエンタープライズ向けのダッシュボードに携わったことがありますが、そこではチームが毎週10時間以上をCSS의競合のデバッグや、6回目となる「同じプライマリボタン」の再作成に費やしていました。Next.js、Tailwind、そしてClass Variance Authority (CVA) をStorybook内で組み合わせることで、コードベースが成長しても整理された状態を維持できるワークフローを構築できます。

Tailwindはユーティリティクラスでスタイリングを処理し、CVAはコンポーネントのバリアントに関する複雑なロジックを厳格に型安全な方法で管理します。Storybookは、これらのコンポーネントがメインのアプリロジックに組み込まれる前にテストされる、独立したサンドボックスとして機能します。最近のプロダクションビルドでは、このスタックによりUI関連のバグが約40%減少し、デザインの引き継ぎが大幅に速くなりました。

「煩雑なコンポーネント」問題

かつては、ランタイムを肥大化させる複雑なテンプレートリテラルやCSS-in-JSライブラリに頼っていました。1つのボタンに5つのサイズ、3つの配色、そしてロード中や無効化といった複数の状態が必要な場合、コードは往々にして解読不能になります。CVAはこれを整理します。バリアントをTypeScriptの型に直接マッピングする構造化された手法を提供します。これにより、「sm」「md」「lg」しか想定していないコンポーネントに、誤って「medium-ish」なサイズを渡してしまうといったミスを防げます。

基盤のセットアップ

まずはクリーンなNext.jsプロジェクトから始めましょう。ゼロから開始する場合は、標準のイニシャライザを使用してください。セットアップ中にTypeScriptとTailwind CSSを有効にすることを忘れないでください。

npx create-next-app@latest my-design-system --typescript --tailwind --eslint

プロジェクトの準備ができたら、ディレクトリに移動してバリアント管理のためのコアツールをインストールします。clsxtailwind-mergeを強くお勧めします。これらは、ユーティリティクラスが競合した際に発生する煩わしいスタイルの上書きを避け、クラスを適切にマージするために不可欠です。

npm install class-variance-authority clsx tailwind-merge

最後に、Storybookを初期化します。このコマンドはNext.jsを自動的に検出し、設定を自動で処理してくれます。

npx storybook@latest init

ユーティリティ層の設定

コンポーネントを書く前に、Tailwindのクラスをクリーンにマージするためのユーティリティが必要です。Tailwindのユーティリティファーストのアプローチは素晴らしいですが、p-4p-2を同時に適用しようとするなど、クラスが競合すると煩雑になることがあります。tailwind-mergeは、最後に定義されたクラスが確実に適用されるようにすることで、この問題を解決します。

src/lib/utils.tsにファイルを作成します:

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

このcn関数は非常に便利です。すべてのコンポーネントで、CVAが生成したクラスとprops経由で渡されたカスタムクラスを組み合わせるために使用します。

デザイン・トークンの定義

tailwind.config.tsがブランドのデザインを反映していることを確認してください。コンポーネント内に16進数の値をハードコードしてはいけません。代わりに設定ファイルで定義しましょう。これにより、システムに真の柔軟性が生まれます。ブランドのプライマリカラー(青)が変更された場合、20個のファイルを検索するのではなく、設定ファイルの1行を更新するだけで済みます。

CVAによる型安全なボタンの構築

最初のビルディングブロックとして、Buttonコンポーネントを作成しましょう。CVAを使用すると、ベースのスタイルを定義し、用途やサイズに応じたバリエーションを指定できます。src/components/ui/Button.tsxを作成します。

import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        primary: 'bg-blue-600 text-white hover:bg-blue-700',
        outline: 'border border-gray-300 bg-transparent hover:bg-gray-100 text-gray-900',
        ghost: 'hover:bg-gray-100 text-gray-700',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        md: 'h-10 px-4 py-2',
        lg: 'h-12 px-8 text-base',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

export { Button, buttonVariants };

ここで工夫されているのは、ButtonPropsインターフェースにvariantsizeが型付きプロパティとして自動的に含まれる点です。開発者が定義されていないvariant="danger"を使おうとすると、TypeScriptが即座にエラーを返し、壊れたUIが本番環境に出るのを防いでくれます。

Storybookによるインタラクティブなドキュメント

では、このボタンを披露しましょう。src/components/ui/Button.stories.tsxを作成します:

import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'UI/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'outline', 'ghost'],
    },
    size: {
      control: 'select',
      options: ['sm', 'md', 'lg'],
    },
  },
};

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

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'クリック',
  },
};

export const Outline: Story = {
  args: {
    variant: 'outline',
    children: 'セカンダリアクション',
  },
};

npm run storybookを実行します。これで、デザイナーがバリアントをテストでき、開発者が実際に動作する実装コードをコピーできる、生きたドキュメントサイトが手に入ります。

検証とガードレール

デザインシステムは、開発者がそれを信頼して初めて価値を持ちます。コンポーネントライブラリができたら、小さな変更が他のすべてを壊さないようにする必要があります。私は、安定性を保つために3層の戦略をとっています:

1. ビジュアル回帰テスト

Chromaticのようなツールを使用して、すべてのコンポーネントの状態のスナップショットを撮ります。ベースのマージンやカラー変数を微調整すると、Chromaticはその変更の影響を受けるすべてのコンポーネントにフラグを立てます。これにより、デプロイ後にようやく気付くような「偶発的な」デザインのズレを防ぐことができます。

2. 自動アクセシビリティ監査

@storybook/addon-a11yは必須です。ブラウザ上でWCAG基準に沿った自動チェックを実行します。「ghost」ボタンのコントラストが不十分な場合や、アイコンのみのボタンにaria-labelが欠けている場合にアラートを表示し、アプリが誰にとっても使いやすいものであることを保証します。

3. ビルド時の安全性

すべてのプルリクエストでtsc --noEmitnext lintが実行されるようにCI/CDパイプラインを設定します。TypeScriptでCVAを使用しているため、ほとんどの破壊的変更はコードがマージされる前に検出されます。レガシーページでまだ使用されているバリアントを誰かが削除した場合、ビルドが失敗し、本番環境での実行時クラッシュを回避できます。

これをセットアップするには多少の初期コストがかかりますが、それに見合う見返りがあります。単にCSSを書いているのではなく、チーム全員が理解できる予測可能でドキュメント化された「言語」を構築しているのです。これにより、UI開発は推測の域を脱し、合理化されたプロセスへと変わります。

Share: