T-CREATOR

Tailwind CSS コンポーネント API 設計:variants/compound variants を整理

Tailwind CSS コンポーネント API 設計:variants/compound variants を整理

Tailwind CSS でコンポーネントを設計する際、スタイルのバリエーションをどう管理するかは重要な課題です。特に、size や color などの props に応じてスタイルを切り替える仕組みは、保守性と拡張性に大きく影響します。

本記事では、Tailwind CSS を使ったコンポーネント設計において、variants(バリエーション)と compound variants(複合バリエーション)という API 設計パターンを整理し、実践的な実装方法を解説いたします。cva(class-variance-authority)や tailwind-variants といったライブラリを活用しながら、型安全で保守しやすいコンポーネントの作り方をご紹介します。

背景

Tailwind CSS のユーティリティクラスと課題

Tailwind CSS は、ユーティリティファーストの CSS フレームワークとして広く利用されています。bg-blue-500text-lg といった細かいクラスを組み合わせることで、柔軟なスタイリングが可能です。

しかし、React や Vue などのコンポーネントフレームワークと組み合わせる際、以下のような課題が生じます。

  • 条件分岐の複雑化: props に応じて異なるクラスを適用する際、条件分岐が増えて可読性が低下する
  • 型安全性の欠如: props とクラスの対応関係が曖昧になり、TypeScript の恩恵を受けにくい
  • 保守性の低下: クラス名の変更や追加が複数箇所に影響し、修正漏れが発生しやすい

コンポーネント API 設計の重要性

これらの課題を解決するには、コンポーネントの API 設計 を適切に行うことが不可欠です。API 設計とは、コンポーネントがどのような props を受け取り、どのようなスタイルを適用するかを定義することを指します。

以下の図は、コンポーネント設計における props とスタイルの関係を示しています。

mermaidflowchart TB
  props["Props<br/>(size, color, variant)"] -->|API 設計| logic["スタイル<br/>ロジック"]
  logic -->|クラス生成| classes["Tailwind<br/>クラス"]
  classes -->|適用| component["コンポーネント<br/>出力"]

図で理解できる要点:

  • Props からスタイルロジックを経由してクラスが生成される流れ
  • API 設計がスタイルロジックの品質を左右する
  • 適切な設計により、保守性と拡張性が向上する

variants パターンの登場

この課題を解決するために、variants パターンという設計手法が広まりました。variants は、props の値に応じてスタイルのバリエーションを定義する仕組みです。

課題

従来の条件分岐による実装の問題点

variants パターンを使わない場合、以下のようなコードになりがちです。

従来の実装例(問題のあるコード)

typescriptinterface ButtonProps {
  size?: 'sm' | 'md' | 'lg';
  color?: 'primary' | 'secondary' | 'danger';
  variant?: 'solid' | 'outline';
}

上記は型定義です。続いて、実際のコンポーネント実装を見てみましょう。

typescriptconst Button: React.FC<ButtonProps> = ({
  size = 'md',
  color = 'primary',
  variant = 'solid',
  children
}) => {
  // クラス名を条件分岐で組み立て
  let className = 'rounded font-semibold';

  // サイズの分岐
  if (size === 'sm') {
    className += ' px-3 py-1.5 text-sm';
  } else if (size === 'md') {
    className += ' px-4 py-2 text-base';
  } else if (size === 'lg') {
    className += ' px-6 py-3 text-lg';
  }

続いて、color と variant の分岐も見てみましょう。

typescript  // color と variant の組み合わせ分岐
  if (variant === 'solid') {
    if (color === 'primary') {
      className += ' bg-blue-500 text-white';
    } else if (color === 'secondary') {
      className += ' bg-gray-500 text-white';
    } else if (color === 'danger') {
      className += ' bg-red-500 text-white';
    }
  } else if (variant === 'outline') {
    if (color === 'primary') {
      className += ' border-2 border-blue-500 text-blue-500';
    } else if (color === 'secondary') {
      className += ' border-2 border-gray-500 text-gray-500';
    } else if (color === 'danger') {
      className += ' border-2 border-red-500 text-red-500';
    }
  }

  return <button className={className}>{children}</button>;
};

問題点の整理

上記のコードには、以下のような問題があります。

#問題点詳細
1ネストの深さ条件分岐が多重にネストし、可読性が著しく低下
2重複コード同じようなクラス名の組み合わせが複数箇所に出現
3拡張の困難さ新しい variant や color を追加する際、複数の分岐を修正する必要がある
4型安全性の欠如クラス名の誤りや漏れを TypeScript が検出できない
5テストの困難さすべての組み合わせをテストするのが難しい

compound variants の必要性

さらに、複数の props の組み合わせに応じて特別なスタイルを適用したい場合があります。

たとえば、「size が lg かつ variant が outline の場合だけボーダーを太くする」といったケースです。このような複合的な条件を、従来の方法で実装すると、さらに条件分岐が複雑化してしまいます。

以下の図は、variant と compound variant の違いを示しています。

mermaidflowchart LR
  subgraph variant["単一 Variant"]
    prop1["size prop"] -->|sm/md/lg| style1["対応スタイル"]
  end

  subgraph compound["Compound Variant"]
    prop2["size prop"] -->|組み合わせ| combo["複合条件<br/>(size=lg + variant=outline)"]
    prop3["variant prop"] -->|組み合わせ| combo
    combo -->|特別な| style2["スタイル適用"]
  end

図で理解できる要点:

  • 単一 variant は 1 つの prop に対してスタイルを適用
  • compound variant は複数の props の組み合わせに対してスタイルを適用
  • 複合条件により、より細かいデザイン制御が可能

解決策

class-variance-authority (cva) の導入

これらの課題を解決するために、cva (class-variance-authority) というライブラリが登場しました。cva は、variants と compound variants を宣言的に定義できる強力なツールです。

まず、cva をインストールします。

bashyarn add class-variance-authority clsx

clsx は、クラス名を結合するためのユーティリティライブラリで、cva と組み合わせて使用します。

variants の基本的な定義

cva を使うと、variants を以下のように定義できます。

typescriptimport {
  cva,
  type VariantProps,
} from 'class-variance-authority';

続いて、ボタンコンポーネントの variants を定義します。

typescriptconst buttonVariants = cva(
  // 基本となるクラス(すべての variant で共通)
  'rounded font-semibold transition-colors focus:outline-none focus:ring-2',
  {
    variants: {
      // size variant の定義
      size: {
        sm: 'px-3 py-1.5 text-sm',
        md: 'px-4 py-2 text-base',
        lg: 'px-6 py-3 text-lg',
      },
      // color variant の定義
      color: {
        primary: '',
        secondary: '',
        danger: '',
      },
      // variant の定義
      variant: {
        solid: '',
        outline: 'border-2 bg-transparent',
      },
    },
  }
);

compound variants の定義

次に、compound variants を定義します。これは、複数の props の組み合わせに対して特別なスタイルを適用する仕組みです。

typescriptconst buttonVariants = cva(
  'rounded font-semibold transition-colors focus:outline-none focus:ring-2',
  {
    variants: {
      size: {
        sm: 'px-3 py-1.5 text-sm',
        md: 'px-4 py-2 text-base',
        lg: 'px-6 py-3 text-lg',
      },
      color: {
        primary: '',
        secondary: '',
        danger: '',
      },
      variant: {
        solid: '',
        outline: 'border-2 bg-transparent',
      },
    },
    // compound variants の定義
    compoundVariants: [
      // solid + primary の組み合わせ
      {
        variant: 'solid',
        color: 'primary',
        class: 'bg-blue-500 text-white hover:bg-blue-600',
      },

続いて、他の組み合わせも定義します。

typescript      // solid + secondary の組み合わせ
      {
        variant: 'solid',
        color: 'secondary',
        class: 'bg-gray-500 text-white hover:bg-gray-600',
      },
      // solid + danger の組み合わせ
      {
        variant: 'solid',
        color: 'danger',
        class: 'bg-red-500 text-white hover:bg-red-600',
      },

さらに、outline variant の組み合わせも定義します。

typescript      // outline + primary の組み合わせ
      {
        variant: 'outline',
        color: 'primary',
        class: 'border-blue-500 text-blue-500 hover:bg-blue-50',
      },
      // outline + secondary の組み合わせ
      {
        variant: 'outline',
        color: 'secondary',
        class: 'border-gray-500 text-gray-500 hover:bg-gray-50',
      },
      // outline + danger の組み合わせ
      {
        variant: 'outline',
        color: 'danger',
        class: 'border-red-500 text-red-500 hover:bg-red-50',
      },

最後に、特別な組み合わせ(size が lg で variant が outline の場合)も定義します。

typescript      // 特別な組み合わせ: lg + outline の場合はボーダーを太く
      {
        size: 'lg',
        variant: 'outline',
        class: 'border-4',
      },
    ],
  }
);

デフォルト値の設定

variants にはデフォルト値を設定できます。これにより、props が指定されなかった場合のスタイルを定義できます。

typescriptconst buttonVariants = cva(
  'rounded font-semibold transition-colors focus:outline-none focus:ring-2',
  {
    variants: {
      size: {
        sm: 'px-3 py-1.5 text-sm',
        md: 'px-4 py-2 text-base',
        lg: 'px-6 py-3 text-lg',
      },
      color: {
        primary: '',
        secondary: '',
        danger: '',
      },
      variant: {
        solid: '',
        outline: 'border-2 bg-transparent',
      },
    },
    compoundVariants: [
      // ...(前述の定義)
    ],
    // デフォルト値の設定
    defaultVariants: {
      size: 'md',
      color: 'primary',
      variant: 'solid',
    },
  }
);

具体例

Button コンポーネントの完全な実装

それでは、cva を使った Button コンポーネントの完全な実装を見ていきましょう。

まず、必要なライブラリをインポートします。

typescriptimport React from 'react';
import {
  cva,
  type VariantProps,
} from 'class-variance-authority';
import { clsx } from 'clsx';

次に、buttonVariants を定義します(前述のコードと同じ内容です)。

typescriptconst buttonVariants = cva(
  'rounded font-semibold transition-colors focus:outline-none focus:ring-2',
  {
    variants: {
      size: {
        sm: 'px-3 py-1.5 text-sm',
        md: 'px-4 py-2 text-base',
        lg: 'px-6 py-3 text-lg',
      },
      color: {
        primary: '',
        secondary: '',
        danger: '',
      },
      variant: {
        solid: '',
        outline: 'border-2 bg-transparent',
      },
    },
    compoundVariants: [
      {
        variant: 'solid',
        color: 'primary',
        class: 'bg-blue-500 text-white hover:bg-blue-600',
      },
      {
        variant: 'solid',
        color: 'secondary',
        class: 'bg-gray-500 text-white hover:bg-gray-600',
      },
      {
        variant: 'solid',
        color: 'danger',
        class: 'bg-red-500 text-white hover:bg-red-600',
      },
      {
        variant: 'outline',
        color: 'primary',
        class:
          'border-blue-500 text-blue-500 hover:bg-blue-50',
      },
      {
        variant: 'outline',
        color: 'secondary',
        class:
          'border-gray-500 text-gray-500 hover:bg-gray-50',
      },
      {
        variant: 'outline',
        color: 'danger',
        class:
          'border-red-500 text-red-500 hover:bg-red-50',
      },
      {
        size: 'lg',
        variant: 'outline',
        class: 'border-4',
      },
    ],
    defaultVariants: {
      size: 'md',
      color: 'primary',
      variant: 'solid',
    },
  }
);

続いて、TypeScript の型定義を作成します。VariantProps を使うことで、buttonVariants から自動的に型を生成できます。

typescript// buttonVariants から自動的に型を生成
type ButtonVariantProps = VariantProps<
  typeof buttonVariants
>;

// Button コンポーネントの Props 型定義
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    ButtonVariantProps {
  // 追加の props があればここに定義
}

最後に、Button コンポーネント本体を実装します。

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

Button.displayName = 'Button';

使用例

作成した Button コンポーネントは、以下のように使用できます。

typescriptimport { Button } from './Button';

function App() {
  return (
    <div className="space-y-4 p-8">
      {/* デフォルト値(md, primary, solid) */}
      <Button>デフォルトボタン</Button>

      {/* サイズ違い */}
      <Button size="sm">小さいボタン</Button>
      <Button size="lg">大きいボタン</Button>

続いて、他のバリエーションの使用例です。

typescript      {/* カラー違い */}
      <Button color="secondary">セカンダリボタン</Button>
      <Button color="danger">危険なボタン</Button>

      {/* outline variant */}
      <Button variant="outline">アウトラインボタン</Button>
      <Button variant="outline" color="danger">
        危険なアウトラインボタン
      </Button>

さらに、compound variant が適用される例です。

typescript      {/* compound variant が適用される例 */}
      {/* size="lg" + variant="outline" でボーダーが太くなる */}
      <Button size="lg" variant="outline" color="primary">
        大きいアウトラインボタン(ボーダー太い)
      </Button>

      {/* カスタムクラスの追加も可能 */}
      <Button className="shadow-lg">影付きボタン</Button>
    </div>
  );
}

コンポーネント設計のフロー

以下の図は、cva を使ったコンポーネント設計のフローを示しています。

mermaidflowchart TD
  start["コンポーネント<br/>設計開始"] --> define_base["基本クラスを定義"]
  define_base --> define_variants["variants を定義<br/>(size, color, variant)"]
  define_variants --> check_compound{"複合条件が<br/>必要か?"}
  check_compound -->|はい| define_compound["compoundVariants<br/>を定義"]
  check_compound -->|いいえ| set_defaults["defaultVariants<br/>を設定"]
  define_compound --> set_defaults
  set_defaults --> create_type["VariantProps で<br/>型生成"]
  create_type --> implement["コンポーネント<br/>実装"]
  implement --> done["完成"]

図で理解できる要点:

  • 基本クラス → variants → compound variants の順に定義
  • 複合条件が不要な場合は compound variants をスキップ
  • VariantProps で型を自動生成することで型安全性を確保

tailwind-variants の活用

cva の代替として、tailwind-variants というライブラリもあります。こちらは、より簡潔な API を提供しています。

まず、tailwind-variants をインストールします。

bashyarn add tailwind-variants

tailwind-variants を使った実装例です。

typescriptimport { tv } from 'tailwind-variants';

const button = tv({
  // 基本クラス
  base: 'rounded font-semibold transition-colors focus:outline-none focus:ring-2',

  // variants の定義
  variants: {
    size: {
      sm: 'px-3 py-1.5 text-sm',
      md: 'px-4 py-2 text-base',
      lg: 'px-6 py-3 text-lg',
    },
    color: {
      primary: '',
      secondary: '',
      danger: '',
    },
    variant: {
      solid: '',
      outline: 'border-2 bg-transparent',
    },
  },

続いて、compound variants とデフォルト値を定義します。

typescript  // compound variants の定義
  compoundVariants: [
    {
      variant: 'solid',
      color: 'primary',
      class: 'bg-blue-500 text-white hover:bg-blue-600',
    },
    {
      variant: 'solid',
      color: 'secondary',
      class: 'bg-gray-500 text-white hover:bg-gray-600',
    },
    {
      variant: 'solid',
      color: 'danger',
      class: 'bg-red-500 text-white hover:bg-red-600',
    },
    {
      variant: 'outline',
      color: 'primary',
      class: 'border-blue-500 text-blue-500 hover:bg-blue-50',
    },
    {
      variant: 'outline',
      color: 'secondary',
      class: 'border-gray-500 text-gray-500 hover:bg-gray-50',
    },
    {
      variant: 'outline',
      color: 'danger',
      class: 'border-red-500 text-red-500 hover:bg-red-50',
    },
    {
      size: 'lg',
      variant: 'outline',
      class: 'border-4',
    },
  ],

  // デフォルト値
  defaultVariants: {
    size: 'md',
    color: 'primary',
    variant: 'solid',
  },
});

tailwind-variants を使ったコンポーネント実装は以下のようになります。

typescriptimport React from 'react';
import { tv, type VariantProps } from 'tailwind-variants';

// button variants の定義(前述のコード)
const button = tv({
  // ...(省略)
});

// 型定義
type ButtonVariantProps = VariantProps<typeof button>;

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    ButtonVariantProps {}

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

cva と tailwind-variants の比較

以下の表は、cva と tailwind-variants の違いをまとめたものです。

#項目cvatailwind-variants
1API の簡潔さ関数呼び出しスタイルオブジェクト設定スタイル
2パッケージサイズ約 1.2KB約 2.5KB
3追加機能シンプルslots、responsive variants など
4TypeScript サポート★★★★★★
5学習コスト低いやや高い

実践的な拡張例:アイコン付きボタン

最後に、実践的な拡張例として、アイコン付きボタンを実装してみます。

typescriptimport { cva, type VariantProps } from 'class-variance-authority';
import { clsx } from 'clsx';
import React from 'react';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded font-semibold transition-colors focus:outline-none focus:ring-2',
  {
    variants: {
      size: {
        sm: 'px-3 py-1.5 text-sm gap-1.5',
        md: 'px-4 py-2 text-base gap-2',
        lg: 'px-6 py-3 text-lg gap-2.5',
      },
      color: {
        primary: '',
        secondary: '',
        danger: '',
      },
      variant: {
        solid: '',
        outline: 'border-2 bg-transparent',
      },

アイコンの位置を制御する variant を追加します。

typescript      // アイコンの位置を制御
      iconPosition: {
        left: 'flex-row',
        right: 'flex-row-reverse',
      },
    },
    compoundVariants: [
      // ...(前述の compound variants と同じ)
      {
        variant: 'solid',
        color: 'primary',
        class: 'bg-blue-500 text-white hover:bg-blue-600',
      },
      {
        variant: 'solid',
        color: 'secondary',
        class: 'bg-gray-500 text-white hover:bg-gray-600',
      },
      {
        variant: 'solid',
        color: 'danger',
        class: 'bg-red-500 text-white hover:bg-red-600',
      },
      {
        variant: 'outline',
        color: 'primary',
        class: 'border-blue-500 text-blue-500 hover:bg-blue-50',
      },
      {
        variant: 'outline',
        color: 'secondary',
        class: 'border-gray-500 text-gray-500 hover:bg-gray-50',
      },
      {
        variant: 'outline',
        color: 'danger',
        class: 'border-red-500 text-red-500 hover:bg-red-50',
      },
    ],
    defaultVariants: {
      size: 'md',
      color: 'primary',
      variant: 'solid',
      iconPosition: 'left',
    },
  }
);

型定義とコンポーネント実装です。

typescripttype ButtonVariantProps = VariantProps<
  typeof buttonVariants
>;

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    ButtonVariantProps {
  icon?: React.ReactNode;
}

export const Button = React.forwardRef<
  HTMLButtonElement,
  ButtonProps
>(
  (
    {
      className,
      size,
      color,
      variant,
      iconPosition,
      icon,
      children,
      ...props
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        className={clsx(
          buttonVariants({
            size,
            color,
            variant,
            iconPosition,
          }),
          className
        )}
        {...props}
      >
        {icon && (
          <span className='inline-flex'>{icon}</span>
        )}
        {children}
      </button>
    );
  }
);

アイコン付きボタンの使用例です。

typescriptimport { Button } from './Button';
import {
  ChevronRightIcon,
  TrashIcon,
} from '@heroicons/react/24/solid';

function App() {
  return (
    <div className='space-y-4 p-8'>
      {/* 左側にアイコン */}
      <Button
        icon={<ChevronRightIcon className='w-5 h-5' />}
      >
        次へ進む
      </Button>

      {/* 右側にアイコン */}
      <Button
        icon={<ChevronRightIcon className='w-5 h-5' />}
        iconPosition='right'
      >
        次へ進む
      </Button>

      {/* 削除ボタン(danger color + アイコン) */}
      <Button
        color='danger'
        variant='outline'
        icon={<TrashIcon className='w-5 h-5' />}
      >
        削除する
      </Button>
    </div>
  );
}

まとめ

本記事では、Tailwind CSS を使ったコンポーネント API 設計において、variantscompound variants という設計パターンを解説いたしました。

重要なポイント:

  • variants は、単一の props(size、color など)に応じてスタイルを切り替える仕組みです
  • compound variants は、複数の props の組み合わせに応じて特別なスタイルを適用する仕組みです
  • cvatailwind-variants を使うことで、宣言的かつ型安全に variants を定義できます
  • 条件分岐を減らし、保守性と拡張性を大幅に向上させることができます

従来の条件分岐による実装では、コードが複雑化し、保守が困難になりがちでした。しかし、variants パターンを採用することで、以下のメリットが得られます。

  • 可読性の向上: スタイルの定義が一箇所にまとまり、見通しが良くなります
  • 型安全性: TypeScript により、props とスタイルの対応関係が保証されます
  • 拡張性: 新しい variant を追加する際、既存のコードへの影響を最小限に抑えられます
  • テストの容易さ: 各 variant が独立しているため、テストケースを明確に定義できます

特に、compound variants を活用することで、複雑なデザイン要件にも柔軟に対応できます。size と variant の組み合わせや、state による特別なスタイル適用など、実務でよく遭遇するケースに対応可能です。

今回ご紹介した手法を活用して、保守しやすく拡張性の高いコンポーネントライブラリを構築してみてください。最初は基本的な variants から始めて、必要に応じて compound variants を追加していくのが良いでしょう。

関連リンク