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-500 や text-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 の違いをまとめたものです。
| # | 項目 | cva | tailwind-variants |
|---|---|---|---|
| 1 | API の簡潔さ | 関数呼び出しスタイル | オブジェクト設定スタイル |
| 2 | パッケージサイズ | 約 1.2KB | 約 2.5KB |
| 3 | 追加機能 | シンプル | slots、responsive variants など |
| 4 | TypeScript サポート | ★★★ | ★★★ |
| 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 設計において、variants と compound variants という設計パターンを解説いたしました。
重要なポイント:
- variants は、単一の props(size、color など)に応じてスタイルを切り替える仕組みです
- compound variants は、複数の props の組み合わせに応じて特別なスタイルを適用する仕組みです
- cva や tailwind-variants を使うことで、宣言的かつ型安全に variants を定義できます
- 条件分岐を減らし、保守性と拡張性を大幅に向上させることができます
従来の条件分岐による実装では、コードが複雑化し、保守が困難になりがちでした。しかし、variants パターンを採用することで、以下のメリットが得られます。
- 可読性の向上: スタイルの定義が一箇所にまとまり、見通しが良くなります
- 型安全性: TypeScript により、props とスタイルの対応関係が保証されます
- 拡張性: 新しい variant を追加する際、既存のコードへの影響を最小限に抑えられます
- テストの容易さ: 各 variant が独立しているため、テストケースを明確に定義できます
特に、compound variants を活用することで、複雑なデザイン要件にも柔軟に対応できます。size と variant の組み合わせや、state による特別なスタイル適用など、実務でよく遭遇するケースに対応可能です。
今回ご紹介した手法を活用して、保守しやすく拡張性の高いコンポーネントライブラリを構築してみてください。最初は基本的な variants から始めて、必要に応じて compound variants を追加していくのが良いでしょう。
関連リンク
articleEmotion と Tailwind 併用の是非:クラス運用コストと保守性をデータで比較
articleTailwind CSS コンポーネント API 設計:variants/compound variants を整理
articleTailwind CSS コンテナクエリ即戦力レシピ:container/size/inline-size の使いどころ
articleTailwind CSS × SolidJS 初期配線:シグナル駆動 UI と相性抜群の設定
articleTailwind CSS のコンテナクエリ vs 伝統的ブレイクポイント:適応精度を実測
articleTailwind CSS のクラスが消える/縮む原因を特定:ツリーシェイクと safelist 完全対策
articlegpt-oss 推論パラメータ早見表:temperature・top_p・repetition_penalty...その他まとめ
articleLangChain を使わない判断基準:素の API/関数呼び出しで十分なケースと見極めポイント
articleJotai エコシステム最前線:公式&コミュニティ拡張の地図と選び方
articleGPT-5 監査可能な生成系:プロンプト/ツール実行/出力のトレーサビリティ設計
articleFlutter の描画性能を検証:リスト 1 万件・画像大量・アニメ多用の実測レポート
articleJest が得意/不得意な領域を整理:単体・契約・統合・E2E の住み分け最新指針
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来