メインコンテンツまでスキップ

cva:宣言的なバリアント管理とその仕組み

この章で学ぶこと

cva(class-variance-authority)の使い方と、内部で何をしているのかを学びます。コンポーネントのスタイルバリアントを宣言的に管理する方法と、cva が「やらないこと」(競合解決)まで理解します。

cva とは何か

cva は、スタイルのバリアントを宣言的に定義するためのライブラリです。

「バリアント」とは、同じコンポーネントの見た目のバリエーションです。ボタンには「主役(primary)」「脇役(secondary)」「警告(destructive)」などのバリアントがあります。

Button のバリアント
├─ default(主役)
├─ secondary(脇役)
├─ ghost(透明)
└─ destructive(警告/赤)

なぜ cva が必要なのか

cva を使わないと、バリアントの管理は条件分岐の嵐になります。

// ❌ cva なし:if 文の嵐 + 色のハードコード
function Button({ variant, size, children }) {
let className = "inline-flex items-center justify-center rounded-md"

if (variant === "default") {
className += " bg-blue-500 text-white hover:bg-blue-600"
} else if (variant === "secondary") {
className += " bg-gray-200 text-gray-800 hover:bg-gray-300"
} else if (variant === "destructive") {
className += " bg-red-500 text-white hover:bg-red-600"
}

if (size === "sm") {
className += " h-8 px-3"
} else if (size === "lg") {
className += " h-10 px-6"
} else {
className += " h-9 px-4"
}

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

この方法の問題点です。

  • 可読性が低い:どのバリアントがどのスタイルかを把握しにくい
  • 保守性が低い:バリアントの追加・変更が大変
  • 型安全性がない:無効なバリアント名を渡してもエラーにならない
  • 色がハードコードデザイントークンの章で学んだトークン(bg-primary)ではなく bg-blue-500 を直書きしている

cva による解決

// ✅ cva あり:宣言的 + デザイントークン
import { cva } from "class-variance-authority"

const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "bg-destructive text-white hover:bg-destructive/90",
},
size: {
sm: "h-8 px-3",
default: "h-9 px-4",
lg: "h-10 px-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

function Button({ variant, size, children }) {
return <button className={buttonVariants({ variant, size })}>{children}</button>
}

どのバリアントがどのスタイルを持つかが一覧で見え、色もデザイントークンの章のトークンを使っています。

備考

cva はパッケージ名が class-variance-authority です。import { cva } from "class-variance-authority" でインポートします。npm には cva という名前の別パッケージもありますが、これは中身のない別物なので使わないでください(インストールは npm install class-variance-authority)。

cva の基本構造

const variants = cva(
"ベースクラス(常に適用される)",
{
variants: {
バリアント名: {
選択肢A: "クラス名",
選択肢B: "クラス名",
},
},
defaultVariants: {
バリアント名: "選択肢A", // 指定がないときの既定値
},
}
)

cva は「関数を返す関数」

ここが内部理解の第一歩です。cva(...)文字列ではなく、関数を返します

const buttonVariants = cva("inline-flex rounded-md", {
variants: { variant: { default: "bg-primary", ghost: "bg-transparent" } },
})

// buttonVariants は「関数」
typeof buttonVariants // => "function"

// 呼び出して初めてクラス名の「文字列」が得られる
buttonVariants({ variant: "ghost" }) // => "inline-flex rounded-md bg-transparent"
cva(base, config) → (props) => string
(レシピを作る) (呼び出すと文字列を生成)

「レシピ(バリアントの定義)」を一度作っておき、使うたびに props を渡して「料理(クラス名の文字列)」を作る、というイメージです。この設計のおかげで、定義を 1 箇所に置いて何度でも呼び出せます。

実践:段階的に組み立てる

Step 1: ベースクラスのみ

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium")

buttonVariants()
// => "inline-flex items-center justify-center rounded-md font-medium"

Step 2: 1 つのバリアントを追加

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
},
},
})

buttonVariants({ variant: "default" })
// => "...rounded-md font-medium bg-primary text-primary-foreground"

Step 3: 複数のバリアントを組み合わせる

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
},
size: {
sm: "h-8 px-3 text-sm",
lg: "h-10 px-6",
},
},
})

buttonVariants({ variant: "default", size: "lg" })
// => "...font-medium bg-primary text-primary-foreground h-10 px-6"

Step 4: デフォルト値を追加

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
},
size: { sm: "h-8 px-3", default: "h-9 px-4", lg: "h-10 px-6" },
},
defaultVariants: { variant: "default", size: "default" },
})

buttonVariants()
// => 引数なしでも default が適用される
// => "...font-medium bg-primary text-primary-foreground h-9 px-4"

追加のクラス名を渡す

cva が生成するクラスに、className を渡して追加できます。

buttonVariants({ variant: "default", className: "mt-4 shadow-lg" })
// => "...bg-primary text-primary-foreground h-9 px-4 mt-4 shadow-lg"

これにより、コンポーネントの利用者がカスタムスタイルを足せます。ただし、ここに重要な落とし穴があります。

cva はクラスを「組み立てる」だけ — 競合解決はしない

cva は、ベース・バリアント・className順番に連結するだけです。Tailwind のクラスが競合しても、解決はしません。実際に動かすと分かります。

const pad = cva("p-4", {
variants: { tone: { a: "p-2" } },
})

pad({ tone: "a", className: "p-8" })
// => "p-4 p-2 p-8"
// ⚠️ p-4 も p-2 も p-8 も、すべて残る!

p-4p-2p-8 はすべて padding なので、本来は最後の p-8 だけが効いてほしいところです。しかし cva は競合を判断せず、全部つなげて返します(最終的にどれが効くかは CSS の定義順任せになり、予測できません)。

cva の仕事: ベース + バリアント + className を「連結」する
cva がやらないこと: Tailwind クラスの「競合解決」

だから shadcn/ui の Button は、cva の出力を cn(...) で包んでいます。

className={cn(buttonVariants({ variant, size, className }))}
// ↑ cva の連結結果を、cn が競合解決する

この cn(= clsx + tailwind-merge)が競合を解決してくれます。その仕組みは次章で詳しく学びます。

備考

ここが「なぜ cva だけでなく cn も必要なのか」の答えです。cva = 組み立てる / cn = 競合を解決する、という役割分担を押さえておいてください。

compoundVariants:複合条件

特定のバリアントの組み合わせにだけ適用するスタイルを定義できます。

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: { default: "bg-primary text-primary-foreground", outline: "border bg-background" },
size: { sm: "h-8 px-3", lg: "h-10 px-6" },
},
compoundVariants: [
{
// outline かつ lg のときだけ、枠線を太くする
variant: "outline",
size: "lg",
className: "border-2",
},
],
defaultVariants: { variant: "default", size: "sm" },
})

TypeScript との連携:VariantProps

cva の大きな利点が、型の自動生成です。VariantProps を使うと、バリアントの定義から props の型を自動で導けます。

import { cva, type VariantProps } from "class-variance-authority"

const buttonVariants = cva("...", {
variants: {
variant: { default: "...", secondary: "..." },
size: { sm: "...", lg: "..." },
},
})

type ButtonVariants = VariantProps<typeof buttonVariants>
// => { variant?: "default" | "secondary" | null; size?: "sm" | "lg" | null }

定義を変えれば型も自動で追従するので、「定義と型がズレる」事故が起きません。

<Button variant="default">OK</Button> // ✅
<Button variant="invalid">NG</Button> // ❌ TypeScript エラー

型の詳細はTypeScript の章で扱います。

Button での cva(本書の完成形)

完成形の章で見た Button の buttonVariants を、改めて cva の視点で読んでみましょう。

const buttonVariants = cva(
// ベースクラス(すべてのボタンに共通)
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3",
lg: "h-10 px-6",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

色はすべてデザイントークンの章のデザイントークン(bg-primary など)。バリアントは cva で宣言的に。これが「デザインシステムに沿ったバリアント設計」の形です。

この章のまとめ

  • cva はバリアントを宣言的に管理するライブラリ(パッケージ名は class-variance-authority
  • cva(...)関数を返す(レシピを作り、呼び出すとクラス文字列を生成する)
  • ベースクラス / variants / defaultVariants / compoundVariants で構成する
  • VariantProps で型を自動生成できる
  • ⚠️ cva はクラスを連結するだけで、競合解決はしない → だから cn で包む

次章では、その cn 関数(clsx + tailwind-merge)が競合をどう解決するのかを、内部の仕組みまで掘り下げます。