はじめに
Tailwind CSSでコンポーネントを作っていると、こんなコードを書いた経験はないでしょうか。
<button
className={`rounded-full font-medium ${
color === "primary" ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-800"
} ${size === "sm" ? "px-3 py-1 text-sm" : "px-5 py-2 text-base"} ${
disabled ? "opacity-50 pointer-events-none" : "hover:opacity-80"
}`}
>
最初は数個の条件でも、バリエーションが増えるたびに三項演算子とテンプレートリテラルが絡み合い、どのクラスがいつ適用されるのか誰にも分からなくなっていきます。しかもクラスの優先順位が衝突して「指定したはずのスタイルが効かない」という悩みもつきものです。
この「className地獄」を宣言的なAPIで解決してくれるのが、今回紹介するTailwind Variantsです。
Tailwind Variantsとは
Tailwind Variantsは、Tailwind CSSのための「ファーストクラスなバリアントAPI」を提供するライブラリです。StitchesやCVA(class-variance-authority)に着想を得ており、HeroUI(旧NextUI)チームがメンテナンスしています。GitHubスター数は3.3kを超え、Tailwind CSS v4にも対応済みです。
主な特徴
- 宣言的なバリアント定義 - 色・サイズ・状態などのバリエーションをオブジェクトで宣言でき、条件分岐が不要になります
- スロット機能 - カードのように複数の要素からなるコンポーネントを、1つの定義でまとめてスタイリングできます
- コンポーネント合成(extend) - 既存の定義を継承して差分だけを上書きでき、デザインシステムの構築に向いています
- 自動競合解決 - tailwind-mergeとの統合により、
px-3とpx-5のようなクラスの衝突を後勝ちで自動解決します - 完全な型サポート - TypeScriptでバリアント名や値がオートコンプリートされます
- フレームワーク非依存 - ただの関数なので、ReactでもVueでもSvelteでも使えます
インストール
npm、yarn、pnpmのいずれでもインストールできます。
# npm
npm install tailwind-variants
# yarn
yarn add tailwind-variants
# pnpm
pnpm add tailwind-variants
クラスの競合を自動解決したい場合は、内部で使用されるtailwind-mergeも合わせて導入されます。特別な設定は不要で、インストールしたらすぐに使い始められます。
基本的な使い方
中心となるのはtv()関数です。baseに共通スタイル、variantsにバリエーション、defaultVariantsにデフォルト値を定義します。
import { tv } from "tailwind-variants";
const button = tv({
base: "rounded-full font-medium transition-colors",
variants: {
color: {
primary: "bg-blue-500 text-white hover:bg-blue-600",
secondary: "bg-gray-100 text-gray-800 hover:bg-gray-200",
danger: "bg-red-500 text-white hover:bg-red-600",
},
size: {
sm: "px-3 py-1 text-sm",
md: "px-5 py-2 text-base",
lg: "px-7 py-3 text-lg",
},
disabled: {
true: "opacity-50 pointer-events-none",
},
},
defaultVariants: {
color: "primary",
size: "md",
},
});
button();
// => "rounded-full font-medium transition-colors bg-blue-500 text-white hover:bg-blue-600 px-5 py-2 text-base"
button({ color: "danger", size: "lg" });
// => "rounded-full font-medium transition-colors bg-red-500 text-white hover:bg-red-600 px-7 py-3 text-lg"
冒頭の三項演算子まみれのコードと比べてみてください。「どの条件のときに、どのクラスが付くのか」が一目瞭然になりました。disabled: { true: ... }のように、booleanをバリアントとして扱えるのも便利なポイントです。
複合条件はcompoundVariantsで
「primaryかつsmのときだけ影を付けたい」といった複数条件の組み合わせは、compoundVariantsで表現できます。
const button = tv({
base: "rounded-full font-medium",
variants: {
color: {
primary: "bg-blue-500 text-white",
secondary: "bg-gray-100 text-gray-800",
},
size: {
sm: "px-3 py-1 text-sm",
md: "px-5 py-2 text-base",
},
},
compoundVariants: [
{
color: "primary",
size: "sm",
class: "shadow-md shadow-blue-200",
},
],
});
button({ color: "primary", size: "sm" });
// => primaryとsmのクラスに加えて "shadow-md shadow-blue-200" が付与されます
if文のネストで表現していた複雑な条件も、データとして宣言できるので見通しが格段に良くなります。
実践的なユースケース
Reactコンポーネントと型の連携
実際のプロジェクトでは、VariantProps型を使ってコンポーネントのPropsとバリアント定義を同期させるのが定番パターンです。
import { tv, type VariantProps } from "tailwind-variants";
const button = tv({
base: "rounded-full font-medium transition-colors",
variants: {
color: {
primary: "bg-blue-500 text-white hover:bg-blue-600",
secondary: "bg-gray-100 text-gray-800 hover:bg-gray-200",
},
size: {
sm: "px-3 py-1 text-sm",
md: "px-5 py-2 text-base",
},
},
defaultVariants: { color: "primary", size: "md" },
});
type ButtonProps = VariantProps<typeof button> &
React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({ color, size, className, ...props }: ButtonProps) {
return <button className={button({ color, size, className })} {...props} />;
}
バリアントを追加・変更するとPropsの型も自動で追従するため、定義と型がズレる心配がありません。さらにclassNameを渡せば呼び出し側からの上書きも可能で、tailwind-mergeのおかげでpx-5とpx-8のような競合も後から渡した方が勝つように解決されます。
<Button size="md" className="px-8" />
// px-5 は自動的に取り除かれ、px-8 が適用されます
slotsで複数要素をまとめてスタイリング
カードのように複数のパーツを持つコンポーネントにはslotsが威力を発揮します。
import { tv } from "tailwind-variants";
const card = tv({
slots: {
base: "rounded-xl border bg-white p-6 shadow-sm",
title: "text-lg font-bold text-gray-900",
description: "mt-2 text-sm text-gray-600",
action: "mt-4 rounded-full px-4 py-2 text-sm font-medium",
},
variants: {
tone: {
neutral: {
action: "bg-gray-900 text-white hover:bg-gray-700",
},
warning: {
base: "border-amber-300 bg-amber-50",
title: "text-amber-900",
action: "bg-amber-500 text-white hover:bg-amber-600",
},
},
},
defaultVariants: { tone: "neutral" },
});
export function AlertCard() {
const { base, title, description, action } = card({ tone: "warning" });
return (
<div className={base()}>
<h3 className={title()}>プランの有効期限が近づいています</h3>
<p className={description()}>更新手続きは設定ページから行えます。</p>
<button className={action()}>更新する</button>
</div>
);
}
注目すべきは、バリアント側からスロットごとにクラスを指定できる点です。tone: "warning"を切り替えるだけで、枠線・タイトル・ボタンのスタイルが一括で変わります。関連するスタイルが1箇所に集まるので、修正時にファイルを行ったり来たりする必要がありません。
extendでデザインシステムを育てる
extendを使うと、既存の定義を継承した派生コンポーネントを作れます。
const baseButton = tv({
base: "rounded-full font-medium transition-colors",
variants: {
size: {
sm: "px-3 py-1 text-sm",
md: "px-5 py-2 text-base",
},
},
});
const iconButton = tv({
extend: baseButton,
base: "inline-flex items-center gap-2",
variants: {
size: {
sm: "gap-1",
},
},
});
iconButton({ size: "sm" });
// baseButtonのクラスとマージされ、sizeバリアントも統合されます
共通部分を1箇所にまとめておけば、ブランドカラーの変更のような横断的な修正も継承元を直すだけで済みます。小さなプロジェクトから始めて、徐々にデザインシステムへ育てていくアプローチと相性抜群です。
注意点: Tailwind CSS v4でのレスポンシブバリアント
1点だけ注意があります。Tailwind CSS v4ではconfig.content.transformが廃止された影響で、Tailwind Variantsの「レスポンシブバリアント機能」(size={{ initial: "sm", md: "lg" }}のような画面幅ごとのバリアント切り替え)は削除されています。レスポンシブ対応が必要な場合は、従来どおりmd:px-7のようにクラス名へ直接プレフィックスを付けて対応しましょう。
まとめ
Tailwind Variantsを使うと、条件分岐まみれのclassNameを次のように改善できます。
variantsとdefaultVariantsで、条件とスタイルの対応が一目で分かる宣言的な定義になるslotsで複数要素のスタイルを1つの定義に集約できるextendで継承ベースのデザインシステムを構築できる- tailwind-mergeの統合により、クラスの競合を気にせず上書きできる
- TypeScriptの型サポートで、バリアントの指定ミスをコンパイル時に検出できる
ただの関数なのでReact以外のフレームワークでも使え、導入コストはほぼゼロです。まずは三項演算子が3つ以上並んでいるボタンコンポーネントを1つ、tv()で書き直すところから始めてみてください。classNameを読む苦痛から解放される感覚を、きっと実感できるはずです。