YASD-TECH
YASD TECH
# React

CVA(class-variance-authority)でクラスの出し分けを書く(variants・compoundVariants・defaultVariants)

投稿日:2026/10/30

更新日:2026/10/30

ttitleImage

CVA(class-variance-authority)でクラスの出し分けを書く(variants・compoundVariants・defaultVariants)

ボタンやセレクトのように「大きさ」「見た目の種類」で Tailwind のクラスを切り替える部品は、三項演算子で書くとすぐ読めなくなる。
CVA(class-variance-authority)は、その出し分けを 「軸ごとの値 → クラス」の表 として書くための小さなライブラリ。
レビューで「cva で書いた方が最終的なクラスが読みやすい」「compoundVariants は掛け合わせのときに使うもの」「サイズで角丸が変わる作りは良くない」と指摘されたので、仕組みと書き分けをまとめた。

結論

  • CVA は 軸(variant)ごとに「値 → クラス」の表を書くと、props からクラス文字列を作る関数を返す。三項演算子の出し分けをそのまま表に置き換えられる。
  • 1つの軸には1つの関心だけ入れる。size(高さ)に角丸や余白を混ぜない。角丸を変えたいなら角丸の軸を足す。
  • compoundVariants は 2つ以上の軸の組み合わせで初めて決まるクラス に使う。軸1つの条件しか書かないなら、それは variants に書くべきもの。
  • CVA は ぶつかるクラスを消さずに並べるだけ。cn(tailwind-merge)を通し、そもそもぶつからないように枝ごとにクラスを分けて書く。

CVA とは(最小の例)

cva(常に付くクラス, { variants, compoundVariants, defaultVariants }) で、クラスを作る関数ができる。

ts
import { cva } from 'class-variance-authority';

const buttonVariants = cva('inline-flex items-center font-bold', {
  variants: {
    size: {
      sm: 'h-8 px-3 text-sm',
      md: 'h-10 px-4 text-base',
    },
    tone: {
      primary: 'bg-blue-600 text-white',
      ghost: 'bg-transparent text-blue-600',
    },
  },
  defaultVariants: {
    size: 'md',
    tone: 'primary',
  },
});

buttonVariants();
// => 'inline-flex items-center font-bold h-10 px-4 text-base bg-blue-600 text-white'
buttonVariants({ size: 'sm', tone: 'ghost' });
// => 'inline-flex items-center font-bold h-8 px-3 text-sm bg-transparent text-blue-600'
buttonVariants({ class: 'mt-4' });
// => 'inline-flex items-center font-bold h-10 px-4 text-base bg-blue-600 text-white mt-4'

React では、作った関数の結果を className に渡すだけ。

tsx
function Button({ size, tone, className, ...props }: ButtonProps) {
  return <button className={cn(buttonVariants({ size, tone }), className)} {...props} />;
}

cn は tailwind-merge と clsx を包んだ、よくある関数(後述のハマりどころで使う)。

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

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

4つの置き場所

書く場所 役割 例
第1引数(base) 常に付くクラス flex border
variants 1つの軸の値で決まるクラス size: { lg: 'h-12', md: 'h-10' }
compoundVariants 複数の軸が同時に一致したときだけ足すクラス { rounded: 'sm', device: 'mobile', class: '...' }
defaultVariants 省略したときに使う値 { size: 'md' }
呼び出し時の class / className 最後に足すクラス buttonVariants({ class: 'mt-4' })

クラスが並ぶ順番

flowchart TD
    P["props(size・rounded など)"] --> D["省略された軸を defaultVariants で補う"]
    D --> B["base のクラス"]
    B --> V["各軸の variants のクラス"]
    V --> C["compoundVariants のうち<br/>条件がすべて一致したもののクラス"]
    C --> X["呼び出し時の class / className"]
    X --> S["1本のクラス文字列"]
    S --> M["cn(twMerge)でぶつかるクラスを整理"]
  • 出力は base → variants → compoundVariants → class の順に並ぶ('base variant-a variant-b compound extra' になることを確かめた)。
  • compoundVariants の一致判定は、defaultVariants で補ったあとの値 で行われる。何も渡さなくても既定値どうしの組み合わせの compoundVariants は効く。

どこに書くかの決め方

flowchart TD
    Q1{"そのクラスは<br/>いつも付く?"} -->|はい| Base["base(第1引数)"]
    Q1 -->|いいえ| Q2{"1つの軸の値だけで<br/>決まる?"}
    Q2 -->|はい| Var["variants"]
    Q2 -->|いいえ| Q3{"2つ以上の軸の<br/>組み合わせで決まる?"}
    Q3 -->|はい| Comp["compoundVariants"]
    Q3 -->|いいえ| Cls["呼び出し側の className<br/>(その画面だけの調整)"]

三項演算子を cva の表にする(Dialog のフッターの例)

ダイアログのフッターを、レイアウト(mobile / desktop / confirm)で出し分けていた。

Before: 条件が2か所に分かれていて、confirm のときに最終的にどのクラスが付くかは、頭の中で両方を足さないと分からない。

tsx
className={cn(
  'flex w-full items-center gap-2',
  layout === 'mobile' ? 'justify-end' : 'justify-center',
  // confirm は本文とボタンの間を広げる
  layout === 'confirm' && 'pt-2'
)}

After: 値ごとに1行。confirm に付くクラスは confirm の行を見れば全部分かる。

tsx
const dialogFooterVariants = cva('flex w-full items-center gap-2', {
  variants: {
    layout: {
      mobile: 'justify-end',
      desktop: 'justify-center',
      // confirm は本文とボタンの間を広げる
      confirm: 'justify-center pt-2',
    },
  },
});

className={dialogFooterVariants({ layout })}

3つの値すべてで、Before と After の出力が同じになることを確かめた。

layout 出力されるクラス
mobile flex w-full items-center gap-2 justify-end
desktop flex w-full items-center gap-2 justify-center
confirm flex w-full items-center gap-2 justify-center pt-2

「最終的なクラスが読みやすい」とはこのこと。三項演算子は「それ以外」がまとめて else に落ちるので、値が増えたときに意図しない値まで同じ枝に入る。cva の表は値ごとに書くので、増えたら1行足すだけで済む。

ネストした三項演算子(a ? x : b ? y : z)も同じように値ごとの行に直せる。


軸に関心を混ぜない(Select の例)

やりたいこと

予約フォームで、日付・開始時刻・終了時刻の3つのセレクトを、スマホ幅で1行に並べたい。狭いので、このときだけ 角丸を小さく(6px)・左右の余白を詰めたい。

もともとのセレクトは次の2軸だった。

  • size: 高さ(lg = 48px / md = 40px)
  • device: mobile は丸いピル型(rounded-full)、desktop は角丸 6px

案1: size に sm を足す(最初に書いたもの)

ts
const selectTriggerVariants = cva('flex border', {
  variants: {
    size: {
      lg: 'h-12',
      md: 'h-10',
      sm: 'h-10 pr-2 pl-3', // 高さは md と同じ
    },
    device: {
      mobile: 'rounded-full',
      desktop: 'rounded-[6px]',
    },
  },
  // sm は device に関係なく 6px にする
  compoundVariants: [{ size: 'sm', class: 'rounded-[6px]' }],
});

size: 'sm', device: 'mobile' の出力:

text
flex border h-10 pr-2 pl-3 rounded-full rounded-[6px]

レビューで受けた指摘は3つ。

  • sm なのに 高さは md と同じ。名前と中身が合っていない。
  • 高さの軸に 角丸と余白が混ざっている(サイズで角丸が変わる作り)。
  • compoundVariants に 軸を1つしか書いていない。これは掛け合わせではなく、device の角丸を size で打ち消しているだけ。rounded-full と rounded-[6px] が両方並び、どちらが効くかを cn に任せている。

案2: 真偽値の軸 compact を足す(レビューで出た案)

ts
const selectTriggerVariants = cva('flex border px-4', {
  variants: {
    size: { lg: 'h-12', md: 'h-10' },
    device: { mobile: 'rounded-full', desktop: 'rounded-[6px]' },
    compact: { true: 'pr-2 pl-3' },
  },
  // compact は mobile でもピル型にせず 6px にする
  compoundVariants: [{ device: 'mobile', compact: true, class: 'rounded-[6px]' }],
});
  • size は高さだけに戻る。
  • compoundVariants が device × compact の掛け合わせ になり、本来の使い方になる。
  • compact: { true: ... } のように true をキーにすると、真偽値で切り替える軸になる。false や省略のときは何も付かない。

案3: 角丸と余白の軸 rounded を足す(最終形)

レビューで「角丸は別の軸にして、その軸で余白のクラスも付ける」と言われ、デザイン側の名前(角丸 sm、既定は full)にも合わせてこうした。

ts
const selectTriggerVariants = cva('flex border', {
  variants: {
    size: {
      lg: 'h-12',
      md: 'h-10',
    },
    rounded: {
      full: '',
      sm: '',
    },
    device: {
      mobile: '',
      desktop: '',
    },
  },
  compoundVariants: [
    // full: mobile はピル型、desktop は 6px の角丸
    { rounded: 'full', device: 'mobile', class: 'rounded-full px-4' },
    { rounded: 'full', device: 'desktop', class: 'rounded-[6px] px-4' },
    // sm: 1行に複数並べる狭い枠用。device に関係なく 6px・余白を詰める
    { rounded: 'sm', device: 'mobile', class: 'rounded-[6px] pr-2 pl-3' },
    { rounded: 'sm', device: 'desktop', class: 'rounded-[6px] pr-2 pl-3' },
  ],
});
  • rounded と device の 組み合わせ4通りを全部書き出し、角丸と余白はどれか1行だけから付く。
  • variants の値が ''(空文字)なのは、軸と値の名前を宣言するため。クラスは compoundVariants 側で付ける。
  • base から px-4 を外し、余白も各行に入れた。打ち消し合うクラスが1つも並ばない。

出力(すべて size: 'md'):

rounded \ device mobile desktop
full flex border h-10 rounded-full px-4 flex border h-10 rounded-[6px] px-4
sm flex border h-10 rounded-[6px] pr-2 pl-3 flex border h-10 rounded-[6px] pr-2 pl-3

同じ rounded-[6px] pr-2 pl-3 の2行は、配列で1行にまとめても出力は同じ(4通りとも一致を確かめた)。

ts
{ rounded: 'sm', device: ['mobile', 'desktop'], class: 'rounded-[6px] pr-2 pl-3' },

3案の比較

案1: size: sm 案2: compact 案3: rounded
size の意味 高さ+角丸+余白(混ざる) 高さだけ 高さだけ
compoundVariants 軸1つ(打ち消し) device × compact rounded × device を4通り
打ち消し合うクラスが並ぶか 並ぶ(rounded-full rounded-[6px]) 並ぶ(rounded-full と rounded-[6px]、px-4 と pr-2 pl-3) 並ばない
cn が無いとどうなるか ピル型のまま(後述) ピル型のまま(後述) 影響なし
向いている場面 — 「詰める/詰めない」の2択で済むとき デザインに角丸の種類として名前があり、今後も増えうるとき

既定値は defaultVariants か、引数の既定値か

既定値の書き方は2通りある。

ts
// A: cva の defaultVariants に書く
const selectTriggerVariants = cva('...', {
  variants: { /* ... */ },
  defaultVariants: { rounded: 'full' },
});

function SelectTrigger({ size = 'md', rounded, device = 'mobile' }: Props) { /* ... */ }
ts
// B: コンポーネントの引数の既定値に書く
function SelectTrigger({ size = 'md', rounded = 'full', device = 'mobile' }: Props) { /* ... */ }

出力されるクラスはどちらも同じ。違いは コンポーネントの中で rounded を読んだときの値。

A: defaultVariants B: 引数の既定値
cva に渡る値(省略時) undefined → cva の中で full に補われる full
コンポーネントの中の rounded(省略時) undefined full
rounded === 'full' で何かを出し分けると 省略時に一致しない 省略時も一致する

レビューでは「size = 'md' と同じく rounded = 'full' を引数に書くと周りと揃う」と言われ、B にした。どちらかに揃えておけば迷わない。

案3のように クラスを全部 compoundVariants で付けている作りでは、既定値が無いと何も付かない。rounded を省略して既定値も無いと、出力は flex border h-10 になり、角丸も左右の余白も消える。A か B のどちらかは必ず書く。


props の型を VariantProps で作る

軸と値を cva に書いたので、型も cva から取り出せる。

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

const selectTriggerVariants = cva('flex', {
  variants: {
    size: { lg: 'h-12', md: 'h-10' },
    compact: { true: 'pr-2 pl-3' },
  },
});

type SelectTriggerVariantProps = VariantProps<typeof selectTriggerVariants>;
// size?:    'lg' | 'md' | null | undefined
// compact?: boolean | null | undefined
  • 定義していない値(size: 'sm')は型エラーになる。
  • null も受け付ける型になる(下のハマりどころを参照)。props の型を手で 'lg' | 'md' と書く作りなら、null は入ってこない。

ハマりどころ

CVA はぶつかるクラスを消さない

CVA は文字列を並べるだけ。案1・案2では rounded-full と rounded-[6px] が両方出力される。

しかも どちらが効くかは、class 属性に書いた順ではなく、Tailwind が出力する CSS の順 で決まる。Tailwind v4.2.1 で出力を確かめると、CSS は次の順に並んだ。

css
.rounded-\[6px\] { border-radius: 6px; }
.rounded-full { border-radius: calc(infinity * 1px); }

rounded-full の方が後ろにあるので、cn を通さないと「後ろに書いた rounded-[6px]」は負けて、ピル型のまま になる。cn(twMerge)は class 属性で後ろに書いた方を残し、前のものを消してくれる。

ts
cn('rounded-full', 'rounded-[6px]'); // => 'rounded-[6px]'

twMerge は「完全に上書きされるもの」しか消さない

ts
cn('px-4', 'pr-2 pl-3'); // => 'px-4 pr-2 pl-3'(px-4 は残る)

pr-2 は右だけ、pl-3 は左だけなので、px-4(左右両方)を完全には上書きしない。twMerge は px-4 を残す。
この組み合わせは、CSS で px-4(padding-inline)が pr-2・pl-3 より前に出るので、たまたま pr-2 pl-3 が効く。
cn と CSS の順番に頼らず、案3のように枝ごとに排他にして、打ち消し合うクラスを並べない のが安全。

真偽値の軸は false も省略も「何も付かない」

ts
const trigger = cva('gap-2 px-4', { variants: { compact: { true: 'gap-0 pr-2 pl-3' } } });

trigger({ compact: true });  // => 'gap-2 px-4 gap-0 pr-2 pl-3'
trigger({ compact: false }); // => 'gap-2 px-4'
trigger();                   // => 'gap-2 px-4'

false のときだけ付けたいクラスがあるなら false: '...' も書く。

null を渡すと既定値も効かない

ts
buttonVariants({ size: undefined }); // => 既定値 md のクラスが付く
buttonVariants({ size: null });      // => size のクラスが何も付かない

undefined は「省略」として defaultVariants で補われるが、null は「その軸を付けない」という意味になる。

compoundVariants は既定値を補ったあとで判定される

defaultVariants: { rounded: 'full', device: 'mobile' } にしておけば、何も渡さなくても { rounded: 'full', device: 'mobile', ... } の行が効く。既定値を引数に書く(B)場合は、引数で補った値をそのまま cva に渡すので同じ結果になる。


参考

Index

  • CVA(class-variance-authority)でクラスの出し分けを書く(variants・compoundVariants・defaultVariants)
  • 結論
  • CVA とは(最小の例)
  • 4つの置き場所
  • クラスが並ぶ順番
  • どこに書くかの決め方
  • 三項演算子を cva の表にする(Dialog のフッターの例)
  • 軸に関心を混ぜない(Select の例)
  • やりたいこと
  • 案1: size に sm を足す(最初に書いたもの)
  • 案2: 真偽値の軸 compact を足す(レビューで出た案)
  • 案3: 角丸と余白の軸 rounded を足す(最終形)
  • 3案の比較
  • 既定値は defaultVariants か、引数の既定値か
  • props の型を VariantProps で作る
  • ハマりどころ
  • CVA はぶつかるクラスを消さない
  • twMerge は「完全に上書きされるもの」しか消さない
  • 真偽値の軸は false も省略も「何も付かない」
  • null を渡すと既定値も効かない
  • compoundVariants は既定値を補ったあとで判定される
  • 参考