T-CREATOR

<div />

TailwindとTypeScriptとReactのユースケース 型安全なデザインシステムを設計して構築する

2026年1月21日
TailwindとTypeScriptとReactのユースケース 型安全なデザインシステムを設計して構築する

React コンポーネントのスタイリングにおいて、「クラス名のタイプミスで本番障害が発生した」「Props の型定義が曖昧でチーム間で混乱が生じた」という経験はないでしょうか。本記事では、コンポーネント境界と Props 設計を中心に、型安全なデザインシステムの導入手順を解説します。実際に業務で Tailwind CSS と TypeScript を組み合わせた UI/UX 設計を行った経験から、判断基準と具体的な実装パターンをお伝えします。

スタイリング手法と型安全性の比較

手法型安全性Props 設計IDE 補完学習コスト
素の CSS/SCSSなし不可限定的
CSS-in-JS(styled-components)部分的可能あり
Tailwind CSS(素の className)なし不可拡張機能で可低〜中
Tailwind + CVA + TypeScript完全ユニオン型で厳格完全

本記事では、最も型安全性が高い Tailwind + CVA(class-variance-authority)+ TypeScript の組み合わせを詳しく解説します。それぞれの詳細は後述します。

検証環境

  • OS: macOS Sequoia 15.2
  • Node.js: 22.13.0
  • TypeScript: 5.7.3
  • 主要パッケージ:
    • react: 19.0.0
    • tailwindcss: 4.0.0
    • class-variance-authority: 0.7.1
    • tailwind-merge: 2.6.0
    • clsx: 2.1.1
  • 検証日: 2026 年 01 月 21 日

コンポーネント境界と Props 設計が問題になる背景

デザインシステムを構築する際、最も重要なのは「コンポーネントの境界をどこに引くか」と「Props をどう設計するか」です。ここでは、なぜこれらが型安全性と密接に関わるのかを説明します。

スタイリングにおける型安全性の意味

型安全(Type Safety)とは、コンパイル時に型の不整合を検出できる性質を指します。Tailwind CSS と TypeScript を組み合わせることで、以下の安全性を確保できます。

  • Props のバリアント制約: ユニオン型により、存在しないバリアントを指定するとコンパイルエラー
  • クラス名の衝突解決: tailwind-merge により矛盾するクラスを自動解決
  • インターフェースの明確化: コンポーネントの API が型定義として可視化
mermaidflowchart LR
  props["Props 入力"] --> validation["TypeScript<br/>型チェック"]
  validation --> cva["CVA<br/>バリアント解決"]
  cva --> merge["tailwind-merge<br/>クラス結合"]
  merge --> output["最終 className"]

上図は、Props から最終的な className が生成されるまでの流れを示しています。各段階で型安全性が担保されるため、実行時エラーを防げます。

従来のアプローチとその限界

従来の CSS/SCSS ベースのデザインシステムでは、変数の型安全性がありませんでした。

scss// SCSS の例(型安全性なし)
$primary-color: #3b82f6;
$spacing-md: 1rem;

.btn {
  padding: $spacing-md;
  background-color: $primary-color;
}

この方法では、$primary-color に誤って数値を設定しても、ビルド時にエラーが検出されません。

つまずきやすい点: SCSS 変数は実行時まで誤りが検出されないため、本番環境で初めて問題が発覚するケースがあります。

実務で発生した型安全性の課題

実際に業務で Tailwind CSS を TypeScript プロジェクトに導入した際、以下の問題に直面しました。

クラス名のタイプミスによる本番障害

検証の結果、以下のようなコードでタイプミスが本番まで検出されなかった事例がありました。

tsx// ❌ bg-blue-500 を bg-blue-50 と誤記(見た目は大きく異なる)
function Button({ children }: { children: React.ReactNode }) {
  return (
    <button className="bg-blue-50 px-4 py-2 rounded text-white">
      {children}
    </button>
  );
}

bg-blue-500(濃い青)と bg-blue-50(薄い青)では視覚的に全く異なりますが、TypeScript はこれをエラーとして検出しません。className は単なる文字列型だからです。

Props 設計の曖昧さによるチーム間の混乱

実際に試したところ、以下のような曖昧な Props 設計がチーム内で混乱を招きました。

tsx// ❌ 曖昧な型定義
type ButtonProps = {
  variant?: string; // 何が有効な値かわからない
  size?: string; // "small"? "sm"? "s"?
  children: React.ReactNode;
};

この設計では、開発者ごとに異なる値を使用してしまい、デザインの一貫性が損なわれました。

スタイルの競合と上書き問題

業務で問題になったのが、クラスの競合です。

tsx// ❌ text-black と text-white が競合
<button className="text-black text-white px-4 py-2">どちらの色になる?</button>

CSS の仕様上、後に書かれたクラスが優先されるとは限りません。これは CSS ファイル内の定義順序に依存するため、予測困難な動作を引き起こします。

CVA と tailwind-merge を採用した型安全な設計

これらの課題を解決するため、class-variance-authority(CVA)と tailwind-merge を採用しました。採用に至った判断基準と、検討したが採用しなかった案も含めて説明します。

採用した設計:CVA + tailwind-merge + clsx

CVA(class-variance-authority)は、コンポーネントのバリアントをユニオン型として定義し、型安全性を確保するライブラリです。

mermaidflowchart TB
  subgraph component["Button コンポーネント"]
    cva_def["CVA 定義<br/>variants: variant, size"]
    props_type["Props 型<br/>VariantProps&lt;typeof buttonVariants&gt;"]
  end

  subgraph utils["ユーティリティ"]
    clsx_util["clsx<br/>条件付きクラス結合"]
    merge_util["tailwind-merge<br/>競合解決"]
  end

  cva_def --> props_type
  props_type --> clsx_util
  clsx_util --> merge_util
  merge_util --> final["最終出力"]

上図は、採用した設計のアーキテクチャです。CVA で定義したバリアントから Props 型が自動生成され、clsx と tailwind-merge を経て最終的なクラス文字列が生成されます。

採用しなかった案とその理由

検討した案採用しなかった理由
Tailwind のクラス名を手動で型定義保守コストが高く、Tailwind のアップデートに追従困難
CSS-in-JS(styled-components)ランタイムコスト、バンドルサイズ増加
tailwindcss-classnamesメンテナンスが停滞、Tailwind v4 未対応
Tailwind 公式の IntelliSense のみ実行時チェックがなく、CI で検出不可

実際に tailwindcss-classnames を検証しましたが、Tailwind CSS v4 への対応が遅れており、型定義の生成に問題がありました。

型安全なコンポーネント実装の具体例

ここからは、実際に動作確認済みのコードを示しながら、型安全なコンポーネントの実装方法を解説します。

ユーティリティ関数の実装

まず、クラス結合のためのユーティリティ関数を実装します。

typescript// lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

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

cn 関数は、clsx で条件付きクラス結合を行い、tailwind-merge で競合を解決します。これにより text-black text-white のような競合が自動的に解決されます。

つまずきやすい点: twMerge を使わず clsx だけでは、クラスの競合は解決されません。必ず両方を組み合わせて使用してください。

Button コンポーネントのインターフェース設計

型安全な Button コンポーネントを実装します。ユニオン型を活用して、有効なバリアントを厳格に制限します。

typescript// 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 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-blue-600 text-white hover:bg-blue-700',
        destructive: 'bg-red-600 text-white hover:bg-red-700',
        outline: 'border border-gray-300 bg-white hover:bg-gray-100',
        secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
        ghost: 'hover:bg-gray-100',
        link: 'text-blue-600 underline-offset-4 hover:underline',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        default: 'h-10 px-4',
        lg: 'h-12 px-6 text-lg',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

// VariantProps で型を自動生成
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export 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';

このコードのポイントは以下の通りです。

  1. ユニオン型によるバリアント制約: variant には 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' のみ指定可能
  2. VariantProps による型推論: CVA 定義から自動的に Props 型が生成される
  3. className の拡張性: 利用側で追加のクラスを渡せる設計

型安全性の検証

以下のコードは、型エラーとして検出されます。

tsx// ✅ 正しい使用例
<Button variant="destructive" size="lg">削除</Button>

// ❌ コンパイルエラー:'purple' は有効なバリアントではない
<Button variant="purple">エラー</Button>

// ❌ コンパイルエラー:'extra-large' は有効なサイズではない
<Button size="extra-large">エラー</Button>

IDE 上で即座にエラーが表示されるため、実行前に問題を発見できます。

Input コンポーネントの実装

フォーム要素も同様のパターンで型安全に実装できます。

typescript// components/ui/input.tsx
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const inputVariants = cva(
  'flex w-full rounded-md border bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'border-gray-300',
        error: 'border-red-500 focus-visible:ring-red-500',
      },
      inputSize: {
        sm: 'h-8 text-xs',
        default: 'h-10',
        lg: 'h-12 text-base',
      },
    },
    defaultVariants: {
      variant: 'default',
      inputSize: 'default',
    },
  }
);

export interface InputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
    VariantProps<typeof inputVariants> {}

export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, variant, inputSize, ...props }, ref) => {
    return (
      <input
        className={cn(inputVariants({ variant, inputSize }), className)}
        ref={ref}
        {...props}
      />
    );
  }
);
Input.displayName = 'Input';

つまずきやすい点: HTML の input 要素には標準で size 属性があるため、Props 名を inputSize に変更しています。Omit で標準の size を除外することで型の衝突を防いでいます。

Card コンポーネントの合成パターン

複合コンポーネントは、複数のサブコンポーネントを組み合わせて構築します。

typescript// components/ui/card.tsx
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const cardVariants = cva(
  'rounded-lg border bg-white shadow-sm',
  {
    variants: {
      variant: {
        default: 'border-gray-200',
        destructive: 'border-red-200 bg-red-50',
        success: 'border-green-200 bg-green-50',
      },
    },
    defaultVariants: {
      variant: 'default',
    },
  }
);

export interface CardProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof cardVariants> {}

export const Card = React.forwardRef<HTMLDivElement, CardProps>(
  ({ className, variant, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(cardVariants({ variant }), className)}
      {...props}
    />
  )
);
Card.displayName = 'Card';

export const CardHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn('flex flex-col space-y-1.5 p-6', className)}
    {...props}
  />
));
CardHeader.displayName = 'CardHeader';

export const CardTitle = React.forwardRef<
  HTMLHeadingElement,
  React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
  <h3
    ref={ref}
    className={cn('text-xl font-semibold leading-none', className)}
    {...props}
  />
));
CardTitle.displayName = 'CardTitle';

export const CardContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';

合成パターンでは、親コンポーネント(Card)にバリアントを持たせ、子コンポーネント(CardHeader, CardTitle, CardContent)はシンプルなスタイルのみを担当します。

実際の使用例

動作確認済みの使用例を示します。

tsx// app/page.tsx
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";

export default function Page() {
  return (
    <div className="container mx-auto p-8 space-y-8">
      <section className="space-y-4">
        <h2 className="text-2xl font-bold">Button コンポーネント</h2>
        <div className="flex flex-wrap gap-2">
          <Button>デフォルト</Button>
          <Button variant="destructive">削除</Button>
          <Button variant="outline">アウトライン</Button>
          <Button variant="secondary">セカンダリ</Button>
          <Button variant="ghost">ゴースト</Button>
          <Button variant="link">リンク</Button>
        </div>
        <div className="flex flex-wrap gap-2">
          <Button size="sm"></Button>
          <Button size="default"></Button>
          <Button size="lg"></Button>
        </div>
      </section>

      <section className="space-y-4">
        <h2 className="text-2xl font-bold">Input コンポーネント</h2>
        <div className="max-w-md space-y-2">
          <Input placeholder="デフォルト" />
          <Input variant="error" placeholder="エラー状態" />
          <Input inputSize="lg" placeholder="大きいサイズ" />
        </div>
      </section>

      <section className="space-y-4">
        <h2 className="text-2xl font-bold">Card コンポーネント</h2>
        <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
          <Card>
            <CardHeader>
              <CardTitle>デフォルト</CardTitle>
            </CardHeader>
            <CardContent>
              <p>通常のカードです。</p>
            </CardContent>
          </Card>
          <Card variant="success">
            <CardHeader>
              <CardTitle>成功</CardTitle>
            </CardHeader>
            <CardContent>
              <p>成功状態のカードです。</p>
            </CardContent>
          </Card>
          <Card variant="destructive">
            <CardHeader>
              <CardTitle>エラー</CardTitle>
            </CardHeader>
            <CardContent>
              <p>エラー状態のカードです。</p>
            </CardContent>
          </Card>
        </div>
      </section>
    </div>
  );
}

型安全なデザインシステム設計手法の詳細比較

ここまでの内容を踏まえ、各設計手法の詳細を比較します。

観点素の TailwindCSS-in-JSCVA + TypeScript
Props の型安全性なし部分的(手動定義)完全(自動生成)
バリアント管理手動で文字列結合テンプレートリテラル宣言的に定義
クラス競合解決不可不要(CSS-in-JS 内で管理)tailwind-merge で自動
バンドルサイズ最小大(ランタイム含む)小(ビルド時最適化)
IDE 補完拡張機能で可能あり完全(型定義から)
学習コスト中〜高
チーム開発規約依存ライブラリ依存型で強制

向いているユースケース

CVA + TypeScript が向いているケース:

  • 複数人でのチーム開発
  • 長期運用を想定したプロジェクト
  • デザインシステムの構築
  • 型安全性を重視する開発方針

CVA + TypeScript が向かないケース:

  • 小規模な単発プロジェクト
  • プロトタイプ開発(素の Tailwind の方が素早い)
  • CSS-in-JS からの移行コストを許容できない場合

まとめ

Tailwind CSS と TypeScript を組み合わせた型安全なデザインシステムについて、コンポーネント境界と Props 設計を中心に解説しました。

CVA(class-variance-authority)を採用することで、以下のメリットが得られます。

  • ユニオン型によるバリアント制約: 無効な値をコンパイル時に検出
  • インターフェースの自動生成: VariantProps により Props 型が自動推論
  • tailwind-merge との連携: クラス競合を自動解決

ただし、すべてのプロジェクトでこのアプローチが最適とは限りません。プロジェクトの規模、チーム構成、運用期間を考慮して判断してください。小規模なプロトタイプであれば、素の Tailwind CSS で十分なケースもあります。

実際に業務で導入した結果、Props のタイプミスによるバグが大幅に減少し、新しいメンバーのオンボーディングも型定義を見るだけで済むようになりました。型安全性は初期コストがかかりますが、中長期的には開発効率と品質の向上に寄与します。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;