メインコンテンツまでスキップ

cn 関数の仕組み:clsx と tailwind-merge を理解する

この章で学ぶこと

前章で「cva は競合解決をしない」ことを学びました。その競合を解決するのが cn 関数です。この章では、cn を構成する clsxtailwind-merge内部で何をしているのかを、実際の動作結果を見ながら掘り下げます。

cn 関数とは何か

cn 関数は、複数の CSS クラス名を安全に結合するユーティリティです。

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

たった 3 行ですが、2 つのライブラリを組み合わせています。cnclassName の略で、shadcn/ui のコミュニティで広く使われている名前です。

なぜ cn が必要なのか

cn は、2 つの異なる問題を同時に解決します。

問題1:クラス名の条件分岐

// ❌ 文字列の手動結合は面倒
let className = "px-4 py-2 rounded"
if (isActive) className += " bg-primary text-primary-foreground"
if (isDisabled) className += " opacity-50"

問題2:Tailwind クラスの競合

// ❌ 競合したクラスがそのまま残る
const base = "p-4 bg-primary"
const custom = "p-2 bg-destructive"
`${base} ${custom}` // => "p-4 bg-primary p-2 bg-destructive"(どれが効くか不明)

cn はこの両方を解決します。

// ✅ 条件分岐も競合解決もまとめて
cn("p-4 bg-primary", "p-2 bg-destructive") // => "p-2 bg-destructive"

この 2 つの仕事は、それぞれ clsxtailwind-merge が担当します。

clsx の役割と内部挙動

clsx は、いろいろな形の入力を1 つのクラス文字列に正規化するライブラリです(最新は 2 系、約 240 バイトと非常に軽量)。

受け付ける入力

import { clsx } from "clsx"

clsx("foo", "bar") // => "foo bar"(文字列)
clsx(["foo", "bar"]) // => "foo bar"(配列)
clsx({ "bg-primary": true, "opacity-50": false }) // => "bg-primary"(オブジェクト:値が真のキーだけ)
clsx("base", { active: true }, ["extra"]) // => "base active extra"(混在・ネストも可)

falsy な値は自動で除外される

clsx の重要な性質が、falsy な値(false / null / undefined / 0 / "" / NaN)を捨てることです。実際に動かすとこうなります。

clsx(true, false, "", null, undefined, 0, NaN, "keep")
// => "keep"
// 真の文字列 "keep" だけが残り、falsy はすべて消える

この性質のおかげで、isActive && "bg-primary" のような条件式をそのまま渡せます。条件が false なら、結果は false になり、clsx が捨ててくれます。

clsx("base", isActive && "bg-primary", isDisabled && "opacity-50")
// isActive=true, isDisabled=false なら => "base bg-primary"

clsx だけでは競合は解決しない

ただし、clsxつなげるだけです。Tailwind の競合は解決しません。

clsx("p-4", "p-2") // => "p-4 p-2"(両方残る)

tailwind-merge の役割と内部挙動

tailwind-merge(最新は 3 系)は、Tailwind のクラスの競合を解決するライブラリです。

基本:同じ種類のクラスは「後勝ち」

import { twMerge } from "tailwind-merge"

twMerge("p-4", "p-2") // => "p-2"(後が勝つ)
twMerge("bg-primary", "bg-destructive") // => "bg-destructive"
twMerge("p-4", "m-4") // => "p-4 m-4"(種類が違うので両方残る)

仕組み:conflict groups(競合グループ)

tailwind-merge は、Tailwind の各クラスがどの「グループ」に属するかを知っています。たとえば padding は p / px / py / pt / pr / pb / pl というグループを持ち、それぞれの包含関係も理解しています。

ここから、一見ふしぎな非対称な振る舞いが生まれます。実際の結果です。

twMerge("p-4", "px-6") // => "p-4 px-6"(両方残る)
twMerge("px-6", "p-4") // => "p-4"(p-4 だけ残る)
twMerge("p-4", "px-6") → "p-4 px-6"
p-4(上下左右)の後に px-6(左右)が来ても、
px-6 は「左右」だけなので、p-4 の「上下」を消せない → 両方残る

twMerge("px-6", "p-4") → "p-4"
px-6(左右)の後に p-4(上下左右)が来ると、
p-4 は px-6 を完全に含む → px-6 を消して p-4 だけ残る

つまり「後に来たクラスが、前のクラスを完全に上書きできるか」で判定しています。単純な「後勝ち」ではないのがポイントです。

修飾子も理解する

hover:md:dark: などの修飾子は別グループとして扱われます。

twMerge("hover:bg-primary", "hover:bg-destructive") // => "hover:bg-destructive"
twMerge("bg-primary", "hover:bg-destructive") // => "bg-primary hover:bg-destructive"(別物なので両方残る)

なぜ clsx → twMerge の順なのか

cntwMerge(clsx(inputs)) の順で呼びます。この順番には理由があります。

  • clsx(先):条件式・オブジェクト・配列・falsy を、twMerge が扱える「素直な 1 本のクラス文字列」に正規化する
  • twMerge(後):その文字列の中の競合を解決する

clsx を飛ばして twMerge だけを使うとどうなるか確認しましょう。twMerge は falsy 引数を渡してもエラーにはならず、無視します。

twMerge("base", false && "active") // => "base"(false は無視される。エラーにはならない)

エラーにこそなりませんが、twMergeオブジェクト記法({ active: true })を展開できません。条件を表現力豊かに書くには clsx が必要です。だから「clsx で組み立ててから twMerge で整える」という順番になっています。

備考

twMerge には真偽値を渡せない」と説明されることがありますが、正確ではありません。falsy は渡してもエラーにならず、単に無視されます。clsx を使う本当の理由は、オブジェクト記法での条件指定を扱えるようにするためです。

cn の動作フロー

cn("p-4", { "bg-primary": true }, "p-2 m-4")

│ Step 1: clsx(正規化・結合)
│ 入力: "p-4", { "bg-primary": true }, "p-2 m-4"
│ 出力: "p-4 bg-primary p-2 m-4"

│ Step 2: twMerge(競合解決)
│ 入力: "p-4 bg-primary p-2 m-4"
│ 出力: "bg-primary p-2 m-4" ← p-4 が p-2 に負ける

最終結果: "bg-primary p-2 m-4"

パフォーマンス:LRU キャッシュ

tailwind-merge の競合解決には計算コストがあります。そこで、一度計算した結果を LRU キャッシュ(既定で最大 500 件)に保存し、同じ入力が来たら 2 回目以降は即座に返します。リスト描画などで同じボタンを何度も描くケースでも、実用上の速度はほとんど問題になりません。

落とし穴:認識できない独自クラス

tailwind-merge が競合を判定できるのは、Tailwind が知っているクラスだけです。独自に定義したクラスは、競合判定が正しく働かないことがあります。

twMerge("text-sm my-custom-text text-lg")
// my-custom-text は Tailwind が知らないクラスなので、
// 期待どおりに扱われないことがある

独自クラスを正しくマージ対象にしたい場合は、extendTailwindMerge で自分のクラスをグループとして登録します。

import { extendTailwindMerge } from "tailwind-merge"

const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": ["my-custom-text"], // フォントサイズ系として登録
},
},
})
備考

cn を通せば、どんなクラスでも必ず正しくマージされる」と思い込まないことが大切です。tailwind-merge が解決できるのは、あくまで Tailwind の標準クラス(と登録済みの独自クラス)です。

実践的な使用例

例1:条件付きスタイル

function Button({ isActive, isDisabled }: { isActive?: boolean; isDisabled?: boolean }) {
return (
<button
className={cn(
"px-4 py-2 rounded-md",
isActive && "bg-primary text-primary-foreground",
isDisabled && "opacity-50 cursor-not-allowed"
)}
>
Click
</button>
)
}

例2:デフォルトスタイルの上書き

function Card({ className }: { className?: string }) {
return <div className={cn("rounded-lg bg-card p-4 shadow", className)} />
}

// 使う側
<Card className="p-8 bg-muted" />
// => "rounded-lg shadow p-8 bg-muted"
// p-4 が p-8 に、bg-card が bg-muted に上書きされた

例3:複数状態をオブジェクト記法で切り替える

clsx のオブジェクト記法({ "クラス": 条件 })は、排他的な複数状態の出し分けに向いています。Button のバリアントは cva が担いますが、こうした素の状態表現では cn 単体が活躍します。

function StatusBadge({ status }: { status: "success" | "error" | "idle" }) {
return (
<span
className={cn("rounded-full px-2 py-1 text-xs font-medium", {
"bg-green-100 text-green-800": status === "success",
"bg-red-100 text-red-800": status === "error",
"bg-gray-100 text-gray-800": status === "idle",
})}
>
{status}
</span>
)
}

true のキーだけが残るので、status に応じて 1 つの配色だけが適用されます。

例4:値からクラスを動的に選ぶ

三項演算子や && を混ぜると、props の値からクラスを組み立てられます。

function Grid({ columns, isCompact }: { columns: 2 | 3; isCompact: boolean }) {
return (
<div
className={cn(
"grid gap-4",
columns === 2 && "grid-cols-2",
columns === 3 && "grid-cols-3",
isCompact ? "p-2" : "p-6"
)}
>
{/* ... */}
</div>
)
}

clsx が条件に合わない &&false)を捨てるので、該当するクラスだけが残ります。

Button での cn

完成形の章の Button は、例 2 で見た「デフォルトのスタイルに利用者の className を結合する」パターンそのものです。

function Button({ className, variant, size, asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
// ↑ cva が組み立てたクラス + 利用者の className を、cn が競合解決
{...props}
/>
)
}
備考

このコードの asChild ? Slot.Root : "button" は、レンダリングする要素を差し替える仕組み(asChild)で、Slot の章で詳しく扱います。ここでは cn(buttonVariants(...)) の部分 — cva が組み立てたクラスと利用者の classNamecn が競合解決している点に注目してください。

利用者が className で上書きしたとき、cn のおかげで安全に反映されます。

<Button className="px-8">送信</Button>
// buttonVariants の "h-9 px-4 py-2 ..." に、利用者の "px-8" が結合される
// cn が px-4 を px-8 で上書き → 期待どおり px-8 が効く

バージョンと Tailwind の対応

  • clsx:Tailwind のバージョンに依存しません(フレームワーク非依存)
  • tailwind-merge:使っている Tailwind のメジャーバージョンに合わせます
Tailwind CSStailwind-merge
v3v2 系
v4v3 系(本書はこちら)

細かな対応はバージョンごとに更新されるため、正確な組み合わせは公式の互換性ドキュメントを確認してください。

この章のまとめ

  • cn = clsx + tailwind-merge。clsx が組み立て、tailwind-merge が競合解決する
  • clsx は falsy(false/null/undefined/0/""/NaN)を捨て、条件式やオブジェクトを正規化する
  • tailwind-merge は conflict group で競合を判定する(ppx の非対称な振る舞いがその例)
  • 順序が clsx → twMerge なのは、条件指定を先に文字列へ正規化するため
  • 独自クラスは自動では解決されない(extendTailwindMerge で登録する)

次章では、ref を子コンポーネントに渡す方法を、React 19 のやり方を中心に学びます。