TailwindCSS:clsxとtailwind-mergeを理解する
TailwindCSSでコンポーネントを作っていると、クラス名の結合と上書きという問題にぶつかります。この記事では、その問題を解決する2つのツール、clsxとtailwind-mergeについて実践的に解説します。
問題の発見:クラス名を結合するだけでは足りない
シンプルなボタンコンポーネントを例に見ていきます。
function Button({ variant, className }) {
const baseClasses = "px-4 py-2 rounded font-medium";
const variantClasses = variant === "primary"
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-800";
return (
<button className={`${baseClasses} ${variantClasses} ${className}`}>
Click me
</button>
);
}
// 使用例
<Button variant="primary" className="bg-red-500" />
このコードで、ボタンは何色になるでしょうか?
直感的には「赤」になりそうですが、実際には予測不可能です。なぜなら、TailwindCSSでは:
-
bg-blue-500とbg-red-500の両方が最終的なクラス名に残る - CSSの詳細度(specificity)は同じ
- CSSファイル内での定義順序で決まる(開発者にはコントロールできない)
<!-- 実際の出力 -->
<button class="px-4 py-2 rounded font-medium bg-blue-500 text-white bg-red-500">
Click me
</button>
コンポーネントの外からclassNameでスタイルを上書きしたいのに、期待通りに動作しません。
条件付きクラスとfalse/undefined
さらに、条件付きでクラスを追加しようとすると、別の問題が発生します。
function Button({ variant, disabled, className }) {
return (
<button className={`
px-4 py-2 rounded font-medium
${variant === "primary" ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-800"}
${variant === "secondary" && "border border-gray-300"}
${disabled && "opacity-50 cursor-not-allowed"}
${className}
`}>
Click me
</button>
);
}
variant="primary"、disabled=falseの場合、実際の出力は:
<button class="px-4 py-2 rounded font-medium bg-blue-500 text-white false false undefined">
falseやundefinedが文字列として出力されてしまいます!
解決策を考える
この問題を解決するには、以下の処理が必要です:
const classArray = [
"px-4 py-2 rounded",
variant === "primary" ? "bg-blue-500" : "bg-gray-200",
variant === "secondary" && "border border-gray-300",
disabled && "opacity-50",
className
];
const classes = classArray.filter((cls) => {
return cls;
}).join(" ");
これを毎回書くのは面倒です。
解決策1:clsx - 条件付きクラスを簡潔に
この問題を解決するのがclsxです。
npm install clsx
import clsx from 'clsx';
function Button({ variant, disabled, className }) {
return (
<button className={clsx(
"px-4 py-2 rounded font-medium",
variant === "primary" ? "bg-blue-500 text-white" : "bg-gray-200 text-gray-800",
variant === "secondary" && "border border-gray-300",
disabled && "opacity-50 cursor-not-allowed",
className
)}>
Click me
</button>
);
}
clsxの役割:
-
false、undefined、nullを自動的に除外 - 配列やオブジェクト形式もサポート
- シンプルで読みやすいコードになります
clsxの便利な書き方
// 配列形式
clsx(['px-4', 'py-2', 'rounded'])
// オブジェクト形式(条件がキーの値で決まる)
clsx({
'bg-blue-500': variant === 'primary',
'bg-gray-200': variant === 'secondary',
'opacity-50': disabled
})
// 混在も可能
clsx(
'px-4 py-2',
{
'bg-blue-500': variant === 'primary',
'opacity-50': disabled
},
className
)
ただし、clsxだけでは最初の問題は解決できません。
clsx("bg-blue-500", "bg-red-500")
// 結果: "bg-blue-500 bg-red-500"
// ← 両方残ってしまう!
解決策2:tailwind-merge - クラスの重複を解消
tailwind-mergeは、TailwindCSSのクラスを理解し、重複するプロパティを持つクラスを自動的に削除します。
npm install tailwind-merge
import { twMerge } from 'tailwind-merge';
twMerge("bg-blue-500", "bg-red-500")
// 結果: "bg-red-500"
// ← 後から渡されたものが優先される!
twMerge("px-4 py-2 bg-blue-500", "bg-red-500")
// 結果: "px-4 py-2 bg-red-500"
// ← 背景色だけが置き換わる
tailwind-mergeの特徴:
- 後から渡されたクラスが優先(CSSの原則通り)
- 同じプロパティを持つクラスのみを削除
- レスポンシブ修飾子やダークモードにも対応
複雑なケースにも対応
twMerge(
"bg-blue-500 hover:bg-blue-600",
"bg-red-500"
)
// 結果: "hover:bg-blue-600 bg-red-500"
// ← hover:bg-blue-600 は残る(通常の bg とは別のプロパティとして扱われる)
twMerge(
"bg-blue-500 hover:bg-blue-600",
"bg-red-500 hover:bg-red-700"
)
// 結果: "bg-red-500 hover:bg-red-700"
// ← 両方とも置き換わる
2つを組み合わせる:推奨パターン
実際の開発では、両方を組み合わせて使います。
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';
function Button({ variant, disabled, className }) {
return (
<button className={twMerge(
clsx(
"px-4 py-2 rounded font-medium transition-colors",
variant === "primary" && "bg-blue-500 text-white hover:bg-blue-600",
variant === "secondary" && "bg-gray-200 text-gray-800 hover:bg-gray-300",
disabled && "opacity-50 cursor-not-allowed"
),
className
)}>
Click me
</button>
);
}
処理の流れ:
-
clsx:内側で条件付きクラスを整理し、
false/undefinedを除外 -
twMerge:外側で
classNameとの重複を解消
この順序が重要です。逆にすると:
-
twMergeがfalseを処理できずエラーになる - 重複削除が正しく機能しない
使用例
// variant="primary"で使用
<Button variant="primary">
送信
</Button>
// 出力: "px-4 py-2 rounded font-medium transition-colors bg-blue-500 text-white hover:bg-blue-600"
// 外部からスタイルを上書き
<Button variant="primary" className="bg-green-500 hover:bg-green-600">
成功
</Button>
// 出力: "px-4 py-2 rounded font-medium transition-colors text-white bg-green-500 hover:bg-green-600"
// ← bg-blue-500 と hover:bg-blue-600 が削除され、緑色に置き換わる!
実践:便利なユーティリティ関数
毎回twMerge(clsx(...))と書くのは面倒なので、多くのプロジェクトでは以下のようなユーティリティ関数を作ります。
// lib/utils.js
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
cnという名前について
cnはclassNameの略です。この命名はshadcn/uiというTailwindCSSベースのコンポーネントライブラリで採用され、広く使われるようになりました。
なぜ短い名前なのか?
この関数はほぼすべてのコンポーネントで使用されるため、短い名前には以下のメリットがあります:
- タイプ数が少ない:開発効率が上がります
- コードが読みやすい:長い関数名で行が折り返されません
- 覚えやすい:2文字なので記憶の負担が少なくなります
// cn を使う場合(簡潔)
className={cn("px-4", variant === "primary" && "bg-blue-500", className)}
// 長い名前だったら...
className={classNameMerge("px-4", variant === "primary" && "bg-blue-500", className)}
他のプロジェクトではcx(classnames extended)という名前を使うこともありますが、shadcn/uiの影響でcnが最も一般的になっています。
cnを使った実装例
import { cn } from '@/lib/utils';
function Button({ variant, disabled, className }) {
return (
<button className={cn(
"px-4 py-2 rounded font-medium transition-colors",
variant === "primary" && "bg-blue-500 text-white hover:bg-blue-600",
variant === "secondary" && "bg-gray-200 text-gray-800 hover:bg-gray-300",
disabled && "opacity-50 cursor-not-allowed",
className
)}>
Click me
</button>
);
}
シンプルで読みやすいコードになります。
実践例:様々なコンポーネント
1. カードコンポーネント
function Card({ variant = "default", elevated, className, children }) {
return (
<div className={cn(
"rounded-lg p-6 border",
{
"bg-white border-gray-200": variant === "default",
"bg-blue-50 border-blue-200": variant === "info",
"bg-red-50 border-red-200": variant === "error",
"shadow-lg": elevated
},
className
)}>
{children}
</div>
);
}
// 使用例
<Card variant="info" elevated>
情報カード
</Card>
<Card variant="info" className="bg-purple-50 border-purple-200">
カスタム色のカード
</Card>
2. アラートコンポーネント
function Alert({ type = "info", dismissible, className, children }) {
return (
<div className={cn(
"flex items-center gap-3 p-4 rounded-lg",
{
"bg-blue-100 text-blue-900 border border-blue-200": type === "info",
"bg-green-100 text-green-900 border border-green-200": type === "success",
"bg-yellow-100 text-yellow-900 border border-yellow-200": type === "warning",
"bg-red-100 text-red-900 border border-red-200": type === "error"
},
dismissible && "pr-10",
className
)}>
{children}
</div>
);
}
// 使用例
<Alert type="success">
保存に成功しました
</Alert>
<Alert type="error" className="border-2">
エラーが発生しました
</Alert>
3. バッジコンポーネント(レスポンシブ対応)
function Badge({ size = "md", variant = "default", className, children }) {
return (
<span className={cn(
"inline-flex items-center font-medium rounded-full",
{
"px-2 py-0.5 text-xs": size === "sm",
"px-3 py-1 text-sm": size === "md",
"px-4 py-1.5 text-base": size === "lg"
},
{
"bg-gray-100 text-gray-800": variant === "default",
"bg-blue-100 text-blue-800": variant === "primary",
"bg-green-100 text-green-800": variant === "success"
},
className
)}>
{children}
</span>
);
}
// 使用例
<Badge size="sm" variant="primary">
新着
</Badge>
// レスポンシブでサイズを変更
<Badge className="md:px-5 md:py-2 md:text-lg">
特大バッジ
</Badge>
4. 複雑な条件分岐を含むフォーム入力
function Input({
error,
disabled,
fullWidth,
size = "md",
className,
...props
}) {
return (
<input
className={cn(
"rounded-lg border transition-colors",
"focus:outline-none focus:ring-2",
{
"px-3 py-1.5 text-sm": size === "sm",
"px-4 py-2 text-base": size === "md",
"px-5 py-3 text-lg": size === "lg"
},
{
"border-gray-300 focus:border-blue-500 focus:ring-blue-200": !error && !disabled,
"border-red-500 focus:border-red-500 focus:ring-red-200": error && !disabled,
"bg-gray-100 border-gray-200 cursor-not-allowed": disabled
},
fullWidth && "w-full",
className
)}
disabled={disabled}
{...props}
/>
);
}
// 使用例
<Input placeholder="名前を入力" />
<Input
error
placeholder="メールアドレス"
fullWidth
/>
<Input
disabled
value="編集不可"
className="font-bold"
/>
5. ダークモード対応コンポーネント
function Panel({ className, children }) {
return (
<div className={cn(
"p-6 rounded-xl border",
"bg-white border-gray-200",
"dark:bg-gray-800 dark:border-gray-700",
className
)}>
{children}
</div>
);
}
// 使用例:ダークモードでも別の色を指定
<Panel className="dark:bg-slate-900">
コンテンツ
</Panel>
パフォーマンスについて
パフォーマンスについては以下の通りです:
- clsx:非常に軽量(約200バイト)で高速
- tailwind-merge:少し重い(約8KB)が、実用上問題ないレベル
ビルド時に最適化されるため、本番環境でのパフォーマンス影響はほぼありません。
TypeScript対応
TypeScriptでも両方のライブラリは型定義が含まれているため、特別な設定は不要です。
import { cn } from '@/lib/utils';
interface ButtonProps {
variant?: 'primary' | 'secondary';
disabled?: boolean;
className?: string;
children: React.ReactNode;
}
function Button({ variant, disabled, className, children }: ButtonProps) {
return (
<button className={cn(
"px-4 py-2 rounded font-medium",
variant === "primary" && "bg-blue-500 text-white",
disabled && "opacity-50 cursor-not-allowed",
className
)}>
{children}
</button>
);
}
まとめ
-
clsx:条件付きクラスを簡潔に記述し、
false/undefinedを除外 - tailwind-merge:重複するTailwindCSSクラスを解消し、後勝ちのルールを実現
- 両者の組み合わせ:柔軟で保守性の高いコンポーネントを作成できます
この2つのツールは、TailwindCSSで再利用可能なコンポーネントを作る際に欠かせません。
Discussion