Class Variance Authority(CVA) で Tailwind CSS の className を管理する

フロントエンドエンジニアの上垣です。

Tailwind CSS を使ってコンポーネントをスタイリングする際、 大量の className の管理は避けて通れない課題です。この記事では、Class Variance Authority (CVA)というライブラリを使って、Tailwind CSS の className を管理する方法まとめています。

前提

この記事で紹介するコンポーネントは、React を利用していますが、CVA は React や Tailwind CSSに特化したツールではない ので、CSS Modules や、独自の CSS 定義でも同じように利用できます。

また、記事内で紹介するコンポーネントは、こちらの codeSandbox で動作を確認できますので、合わせて参照してみてください。

バージョン

react 18.2.0

tailwindcss 3.3.2

class-variance-authority 0.6.1

Props から className への変換

コンポーネントのスタイルは、サイズ、色 などの単位(variant)ごとに、props で切り替えられるようにするのが一般的だと思います。

// サイズと色を props で指定する例
<Button size=”small” color="primary" />

Tailwind CSS でスタイリングする場合、props から className への変換(マッピング)が必要です。これに関しては色々な手法(e.g. classnames, clsx) があると思いますが、上手にこの課題を解決してくれるのが、今回紹介する CVA というライブラリです。

CVA を使ってスタイルを定義する

CVA を使った、Button コンポーネントのスタイル定義の例です。

import { cva } from "class-variance-authority";
 
const buttonVariants = cva(
  "inline-flex items-center justify-center text-sm border transition-colors disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
  {
    variants: {
      variant: {
        solid: "text-white",
        outlined: "bg-white",
      },
      color: {
        blue: "border-blue-700",
        red: "border-red-500",
      },
      size: {
        small: "h-8 px-4",
        medium: "h-10 px-4",
      },
    },
    compoundVariants: [
      {
        variant: "solid",
        color: "blue",
        class: "bg-blue-700 hover:bg-blue-800 active:bg-blue-900",
      },
      {
        variant: "solid",
        color: "red",
        class: "bg-red-500 hover:bg-red-600 active:bg-red-700",
      },
      {
        variant: "outlined",
        color: "blue",
        class: "text-blue-700 hover:bg-blue-100 active:bg-blue-200",
      },
      {
        variant: "outlined",
        color: "red",
        class: "text-red-500 hover:bg-red-100 active:bg-red-200",
      },
    ],
    defaultVariants: {
      variant: "solid",
      color: "blue",
      size: "medium",
    },
  }
);

buttonVariants()
// inline-flex ... text-white border-blue-700 h-10 px-4 bg-blue-700 hover:bg-blue-800 active:bg-blue-900

buttonVariants({color: "red"})
// inline-flex ... text-white border-red-500 h-10 px-4 bg-red-500 hover:bg-red-600 active:bg-red-700

ポイント

  • cva に渡す 最初の引数で、ボタンのベースクラスを定義しています。この className は、すべての variant で適用されます。
  • 2番目の引数には、オブジェクトを渡します。variants プロパティで、各 variant ごとの className を、 compoundVariants プロパティで、複数の variant が組み合わさった時に適応される classNameを、 defaultVariants プロパティで、デフォルトの variant を定義しています。
  • cva 関数は戻り値として、className を出力する関数を返すので、buttonVariants には、variant に応じた className が格納されています。

variant と対応する className を構造化できるので、かなり全体の見通しが良くなりました :smile

コンポーネントに任意の className を追加できるようにする

これに加えて、variants として定義されたスタイル意外にも、Material UI の sx props のように、margin, padding など 利用側の文脈で自由に className を追加できると便利です。(ただしこの場合、利用側であらゆる className を上書きできてしまいます。チームで運用する場合は、スタイル拡張のルール化が必要になると思います。)

// margin-top: 16px と width: 100% を追加する例
<Button size=”small” color="primary"  className="mt-4 w-full" />

この場合、cva では variants 意外にも任意のクラスを追加指定できるので、シンプルに cva コンポーネントに追加の className を渡せば OK です。

// 省略
<button className={buttonVariants({ variant, size, color, className })}>
  click
</button>

ただし、cva は className の競合解決は行わないので、常に 追加した className で上書きできるようにするためには、別のソリューションが必要です。 公式では、className の競合解決に tailwind-merge の利用が推奨されています。バンドルサイズが増えるデメリットはありますが、予期しないコンフリクトを減らすために、基本的に tailwind-merge を利用するのがベターだと思います。

import { twMerge } from "tailwind-merge";

// 省略

<button className={twMerge(buttonVariants({ variant, size, color, className }))}>
  click
</button>

Variants から 型定義を生成する

cva が提供している、VariantProps 型は、定義した variants から TypeScript の型を生成してくれます。

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

type ButtonProps = VariantProps<typeof buttonVariants>

これを React.ComponentProps と組み合わせることで、シンプルにコンポーネントの Props の型を定義できます。

export type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>;

この型定義を React コンポーネントと組み合わせると、下記のようになります。

export type ButtonProps = React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants>;

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({  variant, size, color,  className, children, ...props }, ref) => {
    return (
      <button
        className={twMerge(buttonVariants({ variant, size, color, className }))}
        ref={ref}
        {...props}
      >
        {children}
      </button>
    );
  }
);

まとめ

CVA で、Tailwind CSS の className を管理する方法を紹介しました。

実際に作成したコンポーネントはこちらです。

Tailwind CSS はフレームワークやライブラリを選ばず利用できたり、JavaScript に習熟していないメンバーでも比較的メンテナンスしやすいのが大きなメリットだと思っていますが、やはり大量の className の管理が課題になってきます。

適切なコンポーネント分割や、CVA のようなツールを使って className の管理コストを下げられれば、開発スピードを上げたり、スタイルの一貫性を保つのがかなり楽になると思っているので、CVA だけでなく他にも良いプラクティスがあれば、どんどん取り入れていきたいと思っています。