🦓

Why Tailwind Variants?

2025/02/12に公開

ReactプロジェクトでTailwind CSSを使っていると、以下のような辛みを感じることがあります。

<ul>
  {
    linkItems
      .filter((linkItem) => linkItem.published)
      .filter((linkItem, index) => index === linkItems.length - 1)
      .map((linkItem) => (
        <li key={linkItem.id}>
          <Link
            href={linkItem.href}
            className="flex size-full max-w-[500px] flex-col items-center gap-5 border border-white px-4 py-6 transition ease-out-quint after:pointer-events-none after:absolute after:inset-0 after:size-full hover:z-1 hover:border-primary hover:shadow-card lg:px-5 lg:py-7"
          >
            {linkItem.title}
          </Link>
        </li>
      ))
  }
</ul>

CSS Modulesの場合、命名やファイルを横断する手間はあるものの、コードをブロックとして意味のあるまとまりで表現しやすいです。

/* スタイルに名前を付けられる */
.card {
  background-color: var(--card-bg);
  /* ... */

  /* 状態はブロックとして表現できる */
  &:hover {
    transform: translateY(-4px);
  }

  /* レスポンシブもブロックとして表現できる */
  @media (max-width: 768px) {
    font-size: 1.25rem;
  }
}

しかし、マークアップとスタイルは不可分であるため、コンポーネント思考を前提としたときにTailwind CSSの扱いやすさは魅力的です。そんな中、こちらの記事で紹介されていたTailwind Variantsを試していたところ、これらの悩みを解消してくれる最高のライブラリでした。今回はVariants APIを提供する類似のツールであるCVAと比較しつつ、おすすめの使い方を紹介したいと思います。

Tailwind Variantsとは

コンポーネントには色やサイズなど、利用されるコンテキストや状態に応じてスタイルを切り替えるコードが必要になる場合があります。Tailwind VarinatsやCVAが解決するのは、そういった見た目に関する条件分岐でコードが煩雑化してしまうことです。具体的にはVariantsを宣言的に定義するためのAPIを提供します。

以下の表はTailwind Variantsの公式サイトにあったものですが、CVAと比べて機能が豊富であることがわかります。ただ、解決したい課題は同じなので、それらの機能が不要であれば様々なCSSライブラリに対応しており、シンプルなCVAを選んだ方がよさそうです。


公式サイトから引用

CVAと比べて何がうれしいのか

CSVではコンポーネント全体に適用するベースとなるスタイルと、Variantsのみを定義します。一方、Tailwind Variantsではそれらに加えてSlots (コンポーネントを小さく分割したブロック) を定義できる点に着目しました。

// CVAの場合 (ベースとVariantsを定義できる)
const button = cva("font-semibold border rounded", {
  variants: {
    intent: {
      primary: "bg-blue-500 text-white border-transparent",
    },
  },
});
// Tailwind Variantsの場合 (ベースとVariantsに加えて、Slotsを定義できる)
const button = tv({
  base: "font-semibold border rounded",
  variants: {
    intent: {
      primary: "bg-blue-500 text-white border-transparent",
    },
  },
  // Slots (以下のコードでは、ボタンで使われるアイコン) を定義できる
  slots: {
    icon-right: "size-4"
    icon-left: "size-4"
  },
});

Slotsを活用することで、JSXからほとんどのTailwindクラスを取り除けます。その結果、JSXがシンプルになり、スタイルも意味のあるブロックでまとめられるため、コンポーネント全体の可読性が向上します。実際のコードを見てみましょう。

Tailwind Variantsを使ってみる

例えば、こののような5段階の評価を送信できるフォームがあるとします。


デザインはFrontend Mentorからお借りしました

Tailwind Variantsで大部分のスタイルが分離されているため、JSXにはフレックスコンテナのような僅かなスタイルだけが残っています。

RateForm.tsx
import { useMemo, useState } from "react";
import { tv } from "tailwind-variants";
import type { VariantProps } from "tailwind-variants";
import { cn } from "../lib/utils/cn";

export const RateForm = () => {
  const [currentRate, setCurrentRate] = useState<number>();
  // コンポーネント内では、Slotsで定義したスタイルを宣言的に受け取るだけ
  const { base, circle, title, description, rateButton, submitButton } =
    useMemo(
      () => getRateFormVariants({ isRateSelected: !!currentRate }),
      [currentRate],
    );

  const rates = Array.from(new Array(5), (_, i) => i + 1);

  const handleRateChange = (e: React.MouseEvent<HTMLButtonElement>) => {
    const newRate = Number(e.currentTarget.textContent);
    setCurrentRate(newRate === currentRate ? undefined : newRate);
  };

  return (
    <form className={base()}>
      {/* circleは再利用可能なSlotとして2箇所で利用している */}
      <div className={circle()}>
        <img src="/icon-star.svg" alt="Star icon" className="size-4" />
      </div>
      <div>
        <div className={title()}>How did we do?</div>
        <div className={description()}>
          Please let us know how we did with your support request. All feedback
          is appreciated to help us improve our offering!
        </div>
        <div className="flex justify-between">
          {rates.map((rate) => (
            <button
              type="button"
              aria-label={`Rate ${rate}`}
              onClick={handleRateChange}
              aria-current={rate === currentRate}
              {/* twMergeでcircleと他のSlotをマージしている */}
              className={cn(circle(), rateButton())}
            >
              {rate}
            </button>
          ))}
        </div>
      </div>
      <button type="submit" className={submitButton()}>
        Submit
      </button>
    </form>
  );
};

// コンポーネントから分離されたスタイル
const rateForm = tv(
  {
    base: "bg-very-dark-blue flex flex-col gap-y-7.5 rounded-[30px] p-8",
    slots: {
      circle:
        "bg-dark-blue text-light-grey flex size-12 items-center justify-center rounded-full",
      title: "mb-4 text-2xl font-bold text-white",
      description: "text-light-grey mb-6 leading-6",
      rateButton:
        "hover:bg-orange hover:text-very-dark-blue duration-500 hover:cursor-pointer",
      submitButton:
        "bg-orange text-very-dark-blue w-full rounded-3xl py-3 text-center duration-500 hover:cursor-pointer hover:bg-white",
    },
    variants: {
      isRateSelected: {
        true: {
          // Slotのvariantsも定義できる
          rateButton: "aria-current:text-very-dark-blue aria-current:bg-white",
        },
        false: {
          submitButton: "pointer-events-none bg-white",
        },
      },
    },
  },
);

const getRateFormVariants = (variantProps: VariantProps<typeof rateForm>) =>
  rateForm(variantProps);

クラス名が横に伸びるのはTailwind wayなので変わりませんが、スタイルがJSXから切り離されたことで一緒に見なくてもよいのは大きいです。

おわりに

Tailwind Variantsを活用することで、マークアップとスタイルがほどよく分離された、可読性の高いコードを書けることがわかりました。これからもTailwind CSSはスタイリング手法の1つとして使っていくと思うので、こうした便利ライブラリと組み合わせて開発生産性を上げていきたいです。

株式会社FLAT テックブログ

Discussion