投稿日:2026/10/30
更新日:2026/10/30

ボタンやセレクトのように「大きさ」「見た目の種類」で Tailwind のクラスを切り替える部品は、三項演算子で書くとすぐ読めなくなる。
CVA(class-variance-authority)は、その出し分けを 「軸ごとの値 → クラス」の表 として書くための小さなライブラリ。
レビューで「cva で書いた方が最終的なクラスが読みやすい」「compoundVariants は掛け合わせのときに使うもの」「サイズで角丸が変わる作りは良くない」と指摘されたので、仕組みと書き分けをまとめた。
size(高さ)に角丸や余白を混ぜない。角丸を変えたいなら角丸の軸を足す。compoundVariants は 2つ以上の軸の組み合わせで初めて決まるクラス に使う。軸1つの条件しか書かないなら、それは variants に書くべきもの。cn(tailwind-merge)を通し、そもそもぶつからないように枝ごとにクラスを分けて書く。cva(常に付くクラス, { variants, compoundVariants, defaultVariants }) で、クラスを作る関数ができる。
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 に渡すだけ。
function Button({ size, tone, className, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ size, tone }), className)} {...props} />;
}
cn は tailwind-merge と clsx を包んだ、よくある関数(後述のハマりどころで使う)。
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
| 書く場所 | 役割 | 例 |
|---|---|---|
| 第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 variant-a variant-b compound extra' になることを確かめた)。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/>(その画面だけの調整)"]
ダイアログのフッターを、レイアウト(mobile / desktop / confirm)で出し分けていた。
Before: 条件が2か所に分かれていて、confirm のときに最終的にどのクラスが付くかは、頭の中で両方を足さないと分からない。
className={cn(
'flex w-full items-center gap-2',
layout === 'mobile' ? 'justify-end' : 'justify-center',
// confirm は本文とボタンの間を広げる
layout === 'confirm' && 'pt-2'
)}
After: 値ごとに1行。confirm に付くクラスは confirm の行を見れば全部分かる。
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)も同じように値ごとの行に直せる。
予約フォームで、日付・開始時刻・終了時刻の3つのセレクトを、スマホ幅で1行に並べたい。狭いので、このときだけ 角丸を小さく(6px)・左右の余白を詰めたい。
もともとのセレクトは次の2軸だった。
size: 高さ(lg = 48px / md = 40px)device: mobile は丸いピル型(rounded-full)、desktop は角丸 6pxsize に sm を足す(最初に書いたもの)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' の出力:
flex border h-10 pr-2 pl-3 rounded-full rounded-[6px]
レビューで受けた指摘は3つ。
sm なのに 高さは md と同じ。名前と中身が合っていない。device の角丸を size で打ち消しているだけ。rounded-full と rounded-[6px] が両方並び、どちらが効くかを cn に任せている。compact を足す(レビューで出た案)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 は高さだけに戻る。device × compact の掛け合わせ になり、本来の使い方になる。compact: { true: ... } のように true をキーにすると、真偽値で切り替える軸になる。false や省略のときは何も付かない。rounded を足す(最終形)レビューで「角丸は別の軸にして、その軸で余白のクラスも付ける」と言われ、デザイン側の名前(角丸 sm、既定は full)にも合わせてこうした。
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行だけから付く。''(空文字)なのは、軸と値の名前を宣言するため。クラスは compoundVariants 側で付ける。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通りとも一致を確かめた)。{ rounded: 'sm', device: ['mobile', 'desktop'], class: 'rounded-[6px] pr-2 pl-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通りある。
// A: cva の defaultVariants に書く
const selectTriggerVariants = cva('...', {
variants: { /* ... */ },
defaultVariants: { rounded: 'full' },
});
function SelectTrigger({ size = 'md', rounded, device = 'mobile' }: Props) { /* ... */ }
// 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 のどちらかは必ず書く。
VariantProps で作る軸と値を cva に書いたので、型も cva から取り出せる。
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 は文字列を並べるだけ。案1・案2では rounded-full と rounded-[6px] が両方出力される。
しかも どちらが効くかは、class 属性に書いた順ではなく、Tailwind が出力する CSS の順 で決まる。Tailwind v4.2.1 で出力を確かめると、CSS は次の順に並んだ。
.rounded-\[6px\] { border-radius: 6px; }
.rounded-full { border-radius: calc(infinity * 1px); }
rounded-full の方が後ろにあるので、cn を通さないと「後ろに書いた rounded-[6px]」は負けて、ピル型のまま になる。cn(twMerge)は class 属性で後ろに書いた方を残し、前のものを消してくれる。
cn('rounded-full', 'rounded-[6px]'); // => 'rounded-[6px]'
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 も省略も「何も付かない」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 を渡すと既定値も効かないbuttonVariants({ size: undefined }); // => 既定値 md のクラスが付く
buttonVariants({ size: null }); // => size のクラスが何も付かない
undefined は「省略」として defaultVariants で補われるが、null は「その軸を付けない」という意味になる。
defaultVariants: { rounded: 'full', device: 'mobile' } にしておけば、何も渡さなくても { rounded: 'full', device: 'mobile', ... } の行が効く。既定値を引数に書く(B)場合は、引数で補った値をそのまま cva に渡すので同じ結果になる。