🙆

class-variance-authority(cva)を理解する:内部動作から実践まで

に公開

はじめに

shadcn/uiのコンポーネントを見ていると、cvaという関数がよく登場します。一見複雑に見えますが、実際に理解してみると、バリアントを持つコンポーネントを管理するための合理的な仕組みです。

この記事では、cvaの基本的な考え方から内部の動作、そして実際のプロジェクトでの使い方まで、段階的に見ていきます。

cvaとは何か

cvaは「class-variance-authority」の略で、バリアント(種類)に応じて異なるCSSクラスを適用するためのライブラリです。

例えば、以下のようなボタンコンポーネントを考えてみます。

<Button variant="destructive" size="lg">削除</Button>
<Button variant="outline" size="sm">キャンセル</Button>

このように、variantsizeといったpropsによって異なるスタイルが適用されるコンポーネントを作る際に、cvaが役立ちます。

なぜcvaが必要なのか

従来の方法と比較しながら、cvaの利点を見ていきましょう。

従来の実装方法

cvaを使わずに、条件分岐でスタイルを切り替える場合を考えます。

const Button = ({ variant = "default", size = "default", className, ...props }) => {
  let classes = "inline-flex items-center justify-center rounded-md text-sm"

  if (variant === "default") {
    classes += " bg-primary text-white"
  } else if (variant === "destructive") {
    classes += " bg-destructive text-white"
  } else if (variant === "outline") {
    classes += " border border-input"
  }

  if (size === "default") {
    classes += " h-10 px-4 py-2"
  } else if (size === "sm") {
    classes += " h-9 px-3"
  } else if (size === "lg") {
    classes += " h-11 px-8"
  }

  return <button className={cn(classes, className)} {...props} />
}

この実装には、いくつかの課題があります。

  • if文の連鎖で可読性が低下する
  • 新しいバリアントを追加する際、複数箇所を修正する必要がある
  • TypeScriptの型安全性を別途定義する必要がある

cvaを使った実装

同じ機能をcvaで実装すると、以下のようになります。

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm",
  {
    variants: {
      variant: {
        default: "bg-primary text-white",
        destructive: "bg-destructive text-white",
        outline: "border border-input",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 px-3",
        lg: "h-11 px-8",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

const Button = ({ variant, size, className, ...props }) => {
  return (
    <button
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  )
}

オブジェクト形式で宣言的に定義されているため、バリアントの全体像が把握しやすくなっています。また、新しいバリアントを追加する場合も、1箇所の修正で済みます。

cvaの内部動作を理解する

実際にcvaがどのように動作しているのか、段階的に見ていきましょう。

cvaは関数を返す

cvaを呼び出すと、関数が返されます。

const buttonVariants = cva(baseClasses, config)

typeof buttonVariants  // → "function"

なぜ関数を返す設計になっているのでしょうか。それは、propsに応じて動的に異なるクラス名を生成するためです。

// 設定(レシピ)を作成
const buttonVariants = cva(...)

// propsに応じて異なるクラス名を生成(料理を作る)
buttonVariants({ variant: "default", size: "sm" })
buttonVariants({ variant: "destructive", size: "lg" })

クラス名の結合処理

内部では、以下のような処理でクラス名を結合しています。

// 疑似コード
function cva(baseClasses, config) {
  const { variants, defaultVariants } = config

  return function(props = {}) {
    const classes = [baseClasses]

    for (const variantKey in variants) {
      const variantValue = props[variantKey] ?? defaultVariants?.[variantKey]

      if (variantValue) {
        const variantClasses = variants[variantKey][variantValue]
        classes.push(variantClasses)
      }
    }

    return classes.filter(Boolean).join(" ")
  }
}

具体的な動作を見てみます。

buttonVariants({ variant: "destructive", size: "lg" })

// 内部処理
// 1. classes = ["inline-flex items-center..."]
// 2. variant処理: classes = [..., "bg-destructive text-white"]
// 3. size処理: classes = [..., "h-11 px-8"]
// 4. 結合: "inline-flex items-center... bg-destructive text-white h-11 px-8"

defaultVariantsの動作

propsを渡さなかった場合、defaultVariantsが使用されます。

buttonVariants()
// → "inline-flex items-center... bg-primary text-white h-10 px-4 py-2"

型の自動生成

cvaの重要な特徴として、TypeScriptの型が自動生成されることが挙げられます。

const buttonVariants = cva(...)

// VariantPropsで型を抽出
interface ButtonProps extends VariantProps<typeof buttonVariants> {
  className?: string
}
// →
// {
//   variant?: "default" | "destructive" | "outline"
//   size?: "default" | "sm" | "lg"
//   className?: string
// }

VariantPropsは、TypeScriptのParametersユーティリティ型を使って、関数の引数の型を抽出しています。

type VariantProps<T> = Parameters<T>[0]

これにより、VSCodeでの補完が効き、タイポをコンパイル時に検知できます。

// ✅ OK
<Button variant="destructive" size="lg">削除</Button>

// ❌ TypeScriptエラー
<Button variant="warningg" size="lg">警告</Button>

実際のプロジェクトでの使い方

ここからは、実際のプロジェクトでcvaを使う際のポイントを見ていきます。

ファイル配置の設計

cvaを使ったコンポーネント開発では、プロジェクトの規模や要件に応じて適切なファイル構成を選択することが重要です。

variantsファイル分離の判断基準

variantsの定義を別ファイルに分離するかどうかは、以下の基準で判断します。

別ファイルに分離すべきケース:

  • variants定義が50行以上になる場合
  • 複数のvariantやcompoundVariantsを持つ場合
  • デザインシステムとして再利用可能なコンポーネントの場合
  • チーム開発で責任を分離したい場合

同じファイルで良いケース:

  • variants定義が簡潔(30行以下)な場合
  • variantが1-2種類のみの場合
  • プロジェクト固有の使い捨てコンポーネントの場合

推奨ディレクトリ構成

共通コンポーネントを使い回すプロジェクトを前提に、以下のような構成を考えます。

src/
├── lib/
│   ├── utils.ts                 # cn関数
│   └── shared-variants.ts       # 共通variants定義
└── components/
    └── ui/
        ├── button.tsx
        ├── button.variants.ts
        ├── card.tsx
        └── card.variants.ts

各コンポーネントのvariantsを別ファイルに分離することで、定義が大きくなった場合でも見通しが保たれます。また、スタイル定義とロジックの責任が明確になり、変更時の影響範囲を限定できます。

命名規則

  • variantsファイルは.ts(JSXを含まないため)
  • {component名}.variants.tsで統一

エクスポートの原則

variantsファイルはコンポーネント本体からのみimportし、外部には公開しないようにします。

// button.variants.ts
export const buttonVariants = cva(...)

// button.tsx
import { buttonVariants } from "./button.variants"

// 外部から使う時
import { Button } from "@/components/ui/button"  // variantsは直接importしない

実際のファイル構成例

具体的なファイル内容を見てみましょう。

// components/ui/button.variants.ts
import { cva } from "class-variance-authority"

export const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 px-3",
        lg: "h-11 px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)
// components/ui/button.tsx
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { buttonVariants } from "./button.variants"

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size }), className)}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

この構成により、以下のメリットがあります:

  • 関心の分離:スタイル定義(variants)とコンポーネントロジック(tsx)が分離
  • 保守性:デザイン変更時にvariantsファイルのみを修正
  • テスト容易性:variants関数を単体でテストできる
  • 型安全性VariantPropsで型を自動抽出

小規模コンポーネントの場合

variantsが簡潔な場合は、同じファイルに記述することで管理を簡素化できます。

// components/ui/badge.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground",
        secondary: "bg-secondary text-secondary-foreground",
        destructive: "bg-destructive text-destructive-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

export interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

export function Badge({ className, variant, ...props }: BadgeProps) {
  return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

この場合、variants定義が20行程度と簡潔なため、ファイルを分離するオーバーヘッドの方が大きくなります。

compoundVariantsの活用

特定のpropsの組み合わせに対して、条件付きでクラスを追加したい場合があります。

例えば、「プライマリボタンでサイズがlgの時だけ、ホバー時に影を追加したい」という要件を考えます。

const buttonVariants = cva(
  "inline-flex items-center",
  {
    variants: {
      variant: {
        primary: "bg-blue-600 text-white hover:bg-blue-700",
      },
      size: {
        sm: "px-3 py-1",
        lg: "px-6 py-3",
      },
    },
    compoundVariants: [
      {
        variant: "primary",
        size: "lg",
        class: "hover:shadow-lg",  // 大きいボタンだけホバー時に影を追加
      },
    ],
  }
)

compoundVariantsを使うと、複数のvariantの組み合わせに応じたスタイルを定義できます。

内部では、通常のvariantsクラスに加えて、条件に合致したcompoundVariantsのクラスも追加されます。

buttonVariants({ variant: "primary", size: "lg" })
// → "inline-flex items-center bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 hover:shadow-lg"

もしshadow-lgshadow-smのように競合するクラスが含まれる場合、cn関数(tailwind-merge)が後者を優先して解決します。

共通variantsの管理

複数のコンポーネントで同じvariantを使い回したい場合、共通のvariantsを別ファイルに切り出します。

// lib/shared-variants.ts
export const commonVariants = {
  variant: {
    primary: "bg-blue-600 text-white hover:bg-blue-700",
    secondary: "bg-gray-600 text-white hover:bg-gray-700",
    outline: "border-2 border-blue-600 text-blue-600 hover:bg-blue-50",
  },
  size: {
    sm: "px-3 py-1 text-sm",
    md: "px-4 py-2 text-base",
    lg: "px-6 py-3 text-lg",
  },
}

// button.variants.ts
import { commonVariants } from "@/lib/shared-variants"

export const buttonVariants = cva(
  "inline-flex items-center rounded",
  {
    variants: {
      ...commonVariants,  // 共通variantsを展開
      loading: {
        true: "pointer-events-none opacity-70 cursor-wait",
        false: "",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
      loading: false,
    },
  }
)

この方法により、デザインシステムの一貫性を保ちながら、コンポーネント固有のvariantも追加できます。

デザインの変更があった場合も、shared-variants.tsを修正するだけで、すべてのコンポーネントに反映されます。

まとめ

cvaは、バリアントを持つコンポーネントの管理を効率化するツールです。

主なメリットとして、以下の点が挙げられます。

  • オブジェクト形式の宣言的な定義で可読性が向上する
  • Single Source of Truthの原則により、保守性が向上する
  • TypeScriptの型が自動生成され、開発体験が向上する
  • compoundVariantsで複雑な条件分岐を表現できる

実際のプロジェクトでは、ファイル配置や共通variantsの管理方法を工夫することで、デザインシステムとしての一貫性を保ちながら、柔軟に拡張できる構成を作れます。

内部の動作を理解することで、より効果的にcvaを活用できるようになります。

参考

GitHubで編集を提案

Discussion