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

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

この章で学ぶこと​

cva(class-variance-authority)でスタイルのバリアントを宣言的に管理する方法と、cva が内部で何をしているのか、そして 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"

className に渡した文字列が末尾に足されるので、コンポーネントの利用者はカスタムスタイルを付け足せます。ただし、この連結のしかたに落とし穴があります。

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-4・p-2・p-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)が競合を解決してくれます。

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(...) は関数を返す(定義を 1 回書き、呼び出すとクラス文字列を生成する)
  • ベースクラス / variants / defaultVariants / compoundVariants で構成する
  • VariantProps で型を自動生成できる
  • ⚠️ cva はクラスを連結するだけで、競合解決はしない → だから cn で包む

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