Tailwind Variants(npmパッケージ名 tailwind-variants)は、ボタンやカードのように色・サイズ・状態で見た目が変わるコンポーネントのTailwindクラスを、tv() という1つの関数にまとめて定義するライブラリです。2026年10月28日時点の最新安定版はv3.3.1(2026年8月3日)で、v3.3.0からtailwind-mergeを別途インストールしなくてもクラスの競合を解決するようになりました。一方、v0系の解説記事でよく見る responsiveVariants はv1.0.0で削除済みです。Tailwind Variantsの例は3.3.1を基準とし、返り値をコメントに示しています。長い出力の一部は「…」で省略しています。CSSの例はTailwind CSSのCLIで別途検証しています。
まとめ:Tailwind Variantsの要点
- 導入:v3.3以降は
npm i tailwind-variantsだけで済みます。tailwind-mergeは不要です(3.2.2は、tailwind-mergeが無いとimportの時点でERR_MODULE_NOT_FOUNDで落ちることを実測で確認)。 - 定義の骨格:
base(共通)+variants(軸ごとのクラス)+defaultVariants(省略時の値)。組み合わせ条件はcompoundVariants、複数要素はslotsで書きます。 - レスポンシブ:
responsiveVariantsは使えません。クラス文字列にmd:などの接頭辞を直接書きます。旧形式の{ initial, md }を渡すと、JSではエラーにならずにクラスが消えます。 - 落とし穴:
extendで追加したvariantのクラスは親のvariantより前に並ぶため、px-0などが親のpx-3に負けます。上書きしたいクラスはcompoundVariantsに書きます。 - cvaとの選び分け:単一要素の出し分けだけならcva(class-variance-authority)でも足ります。slotsや競合解決の内蔵が要るならTailwind Variantsを選びます。
- 別物に注意:
@custom-variant/@variantはTailwind CSS v4本体のCSSディレクティブで、このライブラリとは関係がありません。
Tailwind Variantsが解決する問題とcvaとの違い
クラス文字列の条件分岐とレシピへの集約
Tailwindでボタンの色とサイズを切り替えると、三項演算子やテンプレート文字列でクラスをつなぐ処理が呼び出し側に散らばります。Tailwind Variantsは、色・サイズ・無効状態のような軸と、軸ごとのクラスをレシピとして1か所に定義し、呼び出し側は button({ color: "danger" }) のように値を渡すだけにします。公式ドキュメントは、各軸を独立したaxis(軸)として組み合わせる考え方だと説明しています。slotsを指定しない場合の返り値はクラス文字列で、指定した場合はスロット関数を持つオブジェクトです。React・Vue・Svelte・素のJSのどれでも使えます。
採用しなくてよい場面もはっきりしています。1回しか使わない要素や、条件が1つしかない要素には、レシピを作る手間の方が大きくなります。採用判断では、同じ見た目の部品を繰り返し使うか、複数の軸による条件分岐を共通化できるかを確認してください。
Tailwind Variantsは、cvaの考え方を拡張する形で始まったライブラリです。公式の比較表に載っている差と、npm registryで確認した最新版を並べると次のとおりです。
| 機能 | Tailwind Variants | cva |
|---|---|---|
| variants・compoundVariants | あり | あり |
| slots・compoundSlots | あり | なし |
| extend(レシピの継承) | あり | なし |
| クラスの競合解決 | 内蔵 | tailwind-mergeを自分で組む |
| 最新安定版 | 3.3.1(2026-08-03) | 0.7.1(2024-11-26) |
cvaは class-variance-authority 0.7.1以降、安定版が出ていません。後継の cva パッケージも、2026年10月28日時点の最新は1.0.0-beta.12(2026年9月17日公開)で、正式版は出ていません(どちらもnpm registryで確認)。単一要素の色とサイズを切り替えるだけならcvaで足ります。アイコン付きボタンやタイトル付きアラートのように複数要素をまとめて出し分ける部品が多いなら、Tailwind Variantsを選んでください。デザインシステムの設計そのものから見直す場合は、一創のUI/UXデザインの支援も参考になります。
tailwind-variants v3.3.1のインストールと依存関係
npm i tailwind-variants
v3.3.0のリリースノートには「Conflict resolution included — no tailwind-merge install required for TV」と書かれています。TypeScriptで書き直したことに伴い、クラスの競合解決はtailwind-mergeを参考にした内部実装へ置き換わりました。tailwindcss もpeer依存として任意扱いになりました。実際に空のプロジェクトで比べると、3.2.2を入れたときは tailwindcss が一緒に入りましたが、3.3.1で入るのは tailwind-variants 1つだけでした。
通常版とlite版の選び方
競合解決が要らない場合は、tailwind-variants/lite からimportします。tv({ base: "px-2", variants: { s: { lg: "px-4" } } }) を1回呼ぶだけの入口ファイルを、esbuild 0.28.2(--bundle --minify --format=esm)でバンドルして測りました。gzipは出力ファイルに gzip -c(既定の圧縮レベル)をかけた値です。
| 版 | 通常版 minify/gzip | lite版 minify/gzip |
|---|---|---|
| 3.3.1 | 40,295B/12,929B | 10,778B/3,765B |
| 3.2.2+tailwind-merge 3.7.0 | 33,433B/10,772B | 4,667B/2,016B |
依存パッケージは減りましたが、バンドルは3.2.2+tailwind-mergeの構成より約2.1KB(gzip)大きくなっています。lite版は競合解決をしないので、tv({ base: "px-2", variants: { size: { lg: "px-4" } } }) に size: "lg" を渡すと px-2 px-4 がそのまま返ります。呼び出し側で class を渡して上書きする使い方をするなら、通常版を使ってください。
3.2系から上げるときの変更点
- tailwind-mergeをTailwind Variantsのためだけに入れていたなら、削除できます。アプリが
twMergeを直接importしているなら残します(公式FAQ)。 cn(...)(config)のように設定を渡す書き方はcnMergeに置き換えます。cnは3.2.2から文字列を直接返します。cnBaseは3.2.0でcxに置き換わりました(競合解決なしの連結)。- 3.3.0には、slotsを持つレシピを続けて呼ぶと、先に返ったスロット関数のクラスが後の呼び出しの値に混ざる不具合(#305)があり、3.3.1で修正されました。上げるなら3.3.1以上にします。
tv()のバリアント定義(base・variants・defaultVariants)
ボタンを例に、色・サイズ・無効状態の見た目を3軸で定義します。真偽値の軸は true をキーにします。この例はクラスを返すだけなので、ボタン自体を無効にするにはHTMLのdisabled属性も設定してください。
import { tv, type VariantProps } from "tailwind-variants";
export const button = tv({
base: "inline-flex items-center rounded-md font-medium",
variants: {
color: {
primary: "bg-blue-600 text-white",
danger: "bg-red-600 text-white",
},
size: {
sm: "h-8 px-3 text-sm",
lg: "h-11 px-6 text-base",
},
disabled: { true: "pointer-events-none opacity-50" },
},
defaultVariants: { color: "primary", size: "sm" },
});
export type ButtonVariants = VariantProps<typeof button>;
button();
// => "inline-flex items-center rounded-md font-medium bg-blue-600 text-white h-8 px-3 text-sm"
button({ disabled: true });
// => "... h-8 px-3 text-sm pointer-events-none opacity-50"
VariantProps<typeof button> で、color?: "primary" | "danger" のような型を取り出せます。Reactコンポーネントのpropsにそのまま継承させれば、呼び出し側で値を補完できます。型の効き方には注意が必要です。TypeScriptでは button({ size: "xl" }) が TS2322 になりますが、JSでは何も起きず、size のクラスが付かないだけです。JSで使う場合も、返されたクラス文字列の自動テストや、渡す値の実行時検証で打ち間違いを検出できます。
compoundVariantsによる組み合わせ条件と追加クラス
「危険色かつ大サイズのときだけ太字」のように、複数の軸がそろったときだけ付けたいクラスは compoundVariants に書きます。値を配列にすると「どれかに一致」になります。次の compoundVariants は、前章で button を定義した tv() の引数オブジェクトの中に追加します。末尾の button() の呼び出しは、定義の外で実行します。
compoundVariants: [
{ color: "danger", size: "lg", class: "font-bold uppercase" },
{ size: ["sm", "lg"], disabled: true, class: "line-through" },
],
button({ color: "danger", size: "lg" });
// => "inline-flex items-center rounded-md bg-red-600 text-white h-11 px-6 text-base font-bold uppercase"
出力では、base の font-medium が消えて font-bold が残っています。compoundVariantsのクラスは最後に並び、同じ種類のクラスは競合解決で後ろが勝つためです。この「最後に並ぶ」性質は、後の章で扱う extend の落とし穴を避けるときにも使います。cvaの compoundVariants も同じ書き方なので、cvaから移るときはこの部分をほぼそのまま持ってこられます。
slotsによる複数要素のスタイル定義
アイコン・タイトル・本文を持つアラートのように、1つの部品に複数の要素がある場合は slots で要素ごとのクラスを定義します。slotsがあるときは、variantsの値も「スロット名→クラス」のオブジェクトで書きます(公式のSlots)。
const alert = tv({
slots: {
base: "flex gap-3 rounded-lg border p-4",
icon: "size-5 shrink-0",
title: "font-semibold",
description: "text-sm",
},
variants: {
tone: {
info: { base: "border-blue-200 bg-blue-50", icon: "text-blue-600" },
danger: { base: "border-red-200 bg-red-50", icon: "text-red-600" },
},
},
compoundSlots: [
{ slots: ["title", "description"], tone: "danger", class: "text-red-800" },
],
defaultVariants: { tone: "info" },
});
const { base, icon, title, description } = alert({ tone: "danger" });
base(); // => "flex gap-3 rounded-lg border p-4 border-red-200 bg-red-50"
title(); // => "font-semibold text-red-800"
description({ class: "text-base" }); // => "text-red-800 text-base"
compoundSlots は、指定した複数のスロットに同じ条件のクラスをまとめて付けます。タイトルと本文の両方に同じ文字色を付けたいときに、variantsへ同じクラスを2回書かずに済みます。スロット関数にも class を渡せて、最後の行では text-sm が text-base に置き換わりました。なお、slots: {} と空のオブジェクトを書くと、base だけを持つスロット関数を返すモードになります。文字列を返してほしい場合は slots 自体を書かないでください(v3.3の挙動変更)。
extendによるレシピの継承と上書き順
extend に既存のレシピを渡すと、base・variants・defaultVariants・compoundVariantsを引き継いだ新しいレシピを作れます。ここに落とし穴があります。アイコンだけのボタンを作るつもりで iconOnly を足した次のコードは、意図どおりに動きません。
const iconButton = tv({
extend: button,
variants: { iconOnly: { true: "w-8 justify-center px-0" } },
});
iconButton({ iconOnly: true });
// => "inline-flex items-center rounded-md font-medium w-8 justify-center bg-blue-600 text-white h-8 px-3 text-sm"
子で足したvariantのクラスが親の color・size より前に並ぶため、px-0 が後ろの px-3 に負けて消えています。3.2.2でも同じ結果でした。親のvariantを打ち消したいクラスは、最後に並ぶ compoundVariants に移します。
const iconButton = tv({
extend: button,
variants: { iconOnly: { true: "" } },
compoundVariants: [{ iconOnly: true, class: "w-8 justify-center px-0" }],
});
iconButton({ iconOnly: true });
// => "... bg-blue-600 text-white h-8 text-sm w-8 justify-center px-0"
継承できる親は1つだけです。複数のレシピを合成したい場合は、extendをつなぐか、クラス文字列を自分で組み立てます。
class・classNameで上書きするときの競合解決
呼び出し時に class(または className)を渡すと、レシピのクラスより後ろに置かれ、同じ種類のクラスは後ろが勝ちます。
button({ class: "bg-green-600 px-8" });
// => "inline-flex items-center rounded-md font-medium text-white h-8 text-sm bg-green-600 px-8"
競合解決は、組み込みのクラス分類規則に従います。独自のクラスでも既存の分類に合えば処理されますが、未知の分類には設定が必要です。3.3.1では cn("bg-brand", "bg-red-500") が bg-red-500 になり、bg- の色として扱われました。一方、elevation-low と elevation-high は、そのままでは両方残りました。twMergeConfig でクラスのグループを教えると、後ろの elevation-high だけが残ります。
const chip = tv(
{ base: "elevation-low", variants: { raised: { true: "elevation-high" } } },
{ twMergeConfig: { extend: { classGroups: { elevation: ["elevation-low", "elevation-high"] } } } },
);
chip({ raised: true }); // => "elevation-high"(設定なしでは "elevation-low elevation-high")
レシピの外でクラスをつなぐときは、競合を解決する cn と、つなぐだけの cx を使い分けます。cn("px-2 py-1", "px-4") は py-1 px-4、cx は px-2 py-1 px-4 を返します(Class Resolution)。
レスポンシブ対応:responsiveVariantsは削除済み
v0系には、{ responsiveVariants: ["md"] } を第2引数に渡し、size: { initial: "sm", md: "lg" } のように画面幅ごとに値を切り替える機能がありました。この機能は、Tailwindの設定ファイルの content.transform でクラスを生成する仕組みに頼っていました。Tailwind CSS v4がその設定を廃止したため、v1.0.0(2025年3月8日)で responsiveVariants は withTv とともに削除されています。
古いコードを3.3.1で動かすと、エラーにはなりません。
button({ size: { initial: "sm", md: "lg" } });
// => "inline-flex items-center rounded-md font-medium bg-blue-600 text-white"
指定したsizeのクラスだけでなく、defaultVariantsの h-8 px-3 text-sm まで消えています。TypeScriptなら TS2322 で気づけますが、JSでは型エラーが出ないため、返り値のテストや画面確認で検出する必要があります。v0系から上げる場合は、initial を含むオブジェクトを渡している箇所をgrepで洗い出してください。
現在の書き方は、公式のResponsiveにあるとおり、クラス文字列に接頭辞を直接書く方法です。
const text = tv({
base: "text-sm md:text-base",
variants: { size: { lg: "text-lg md:text-xl" } },
});
text({ size: "lg" }); // => "text-lg md:text-xl"
競合解決はブレークポイントごとに行われるので、text-sm は text-lg に、md:text-base は md:text-xl に置き換わります。ブレークポイントの設計そのものはレスポンシブデザインの作り方で解説しています。
Tailwind CSS v4で使うときの設定
レシピの書き方はTailwind CSSのv3とv4で変わりません。注意が要るのは、Tailwindがクラスを生成する対象ファイルの側です。v4ではJSの設定ファイルの content ではなく、CSSの @source と自動検出でソースを走査します。tv() を定義したファイルが走査対象から外れていると、レシピに書いたクラスのCSSが生成されません。
@import "tailwindcss";
@source "../components/**/*.{js,ts,jsx,tsx}";
@source "../node_modules/your-ui-lib/dist/**/*.{js,ts,jsx,tsx}";
3行目は、レシピを含むUIライブラリをnode_modulesから読む場合の例です(公式のTailwind CSS v4)。同じ理由で、"bg-" + color + "-500" のように実行時に組み立てたクラス名は生成されません。クラス名はレシピの中に完全な形で書いてください。v3からv4への移行全体はTailwind CSS v3とv4の違い|破壊的変更と移行手順をv4.3.3の実ビルドで検証にまとめています。
tailwind-variants(tv)とTailwind CSS v4の@custom-variant/@variantの違い
「tailwind custom variant」「@custom-variant tailwind」で検索してこの記事に来る人もいますが、@custom-variant と @variant はTailwind CSS v4本体のCSSディレクティブで、tailwind-variantsとは関係がありません。
| 名前 | 正体 | 書く場所 | 役割 |
|---|---|---|---|
| tailwind-variants(tv) | npmライブラリ | JS/TS | 部品の出し分けを定義 |
| @custom-variant | Tailwind v4の構文 | CSS | 独自のバリアントを作る |
| @variant | Tailwind v4の構文 | CSS | 手書きCSSに既存バリアントを適用 |
@tailwindcss/cli 4.3.3で次のCSSをビルドし、出力を確かめました。
@custom-variant midnight (&:where([data-theme="midnight"], [data-theme="midnight"] *));
.card {
background: white;
@variant dark { background: black; }
@variant midnight { background: navy; }
}
@custom-variant で作った midnight は、HTMLで midnight:bg-indigo-950 と書くと .midnight\:bg-indigo-950:where([data-theme="midnight"], ...) というセレクタになりました。@variant dark は @media (prefers-color-scheme: dark) に、@variant midnight は &:where([data-theme="midnight"], ...) に展開されます。どちらもTailwind公式のFunctions and directivesに載っている構文です。@custom-variant はv3のプラグインで使っていた addVariant() の役割をCSSに移したものです。
2つは組み合わせて使えます。tvのレシピに midnight:bg-indigo-950 と書けばそのまま効き、競合解決も cn("midnight:bg-red-500", "midnight:bg-blue-500") → midnight:bg-blue-500 のように独自のバリアントの単位で働きました。JSで部品の出し分けを定義したいならtailwind-variants、CSSでテーマや状態のバリアントを増やしたいなら @custom-variant を使います。
よくある質問
tailwind-variantsにtailwind-mergeは必要ですか?
v3.3.0以降は不要です。競合解決が通常版に内蔵されました。3.2.2以前の通常版は、tailwind-mergeが無いとimportの時点でエラーになります。競合解決が要らなければ、tailwind-variants/lite を使えます。
compoundVariantsはcvaと書き方が違いますか?
同じです。条件のキーと値、class(または className)を並べたオブジェクトの配列で書き、値を配列にすると「どれかに一致」になります。Tailwind Variantsでは、slotsを使う部品向けに、スロットを指定できる compoundSlots も使えます。
responsiveVariantsが効かないのはなぜですか?
v1.0.0で削除されたためです。JSでは { initial, md } 形式の値を渡してもエラーにならず、そのvariantのクラスが付かないだけです。md:text-lg のように、クラス文字列にブレークポイントの接頭辞を直接書いてください。
@custom-variantとtailwind-variantsは同じものですか?
別物です。@custom-variant はTailwind CSS v4のCSSディレクティブで、dark: のようなバリアントを自分で作るためのものです。tailwind-variantsは、JSで部品の出し分けを定義するnpmライブラリです。
Tailwind CSS v4で使えますか?
使えます。レシピはv3とv4で同じ書き方です。v4では、tv() を定義したファイルが @source または自動検出の走査対象に入っていることを確認してください。