TailwindCSS:clsxとtailwind-mergeを理解する

に公開

TailwindCSSでコンポーネントを作っていると、クラス名の結合と上書きという問題にぶつかります。この記事では、その問題を解決する2つのツール、clsxtailwind-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-500bg-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">

falseundefinedが文字列として出力されてしまいます!

解決策を考える

この問題を解決するには、以下の処理が必要です:

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の役割:

  • falseundefinednullを自動的に除外
  • 配列やオブジェクト形式もサポート
  • シンプルで読みやすいコードになります

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>
  );
}

処理の流れ:

  1. clsx:内側で条件付きクラスを整理し、false/undefinedを除外
  2. twMerge:外側でclassNameとの重複を解消

この順序が重要です。逆にすると:

  • twMergefalseを処理できずエラーになる
  • 重複削除が正しく機能しない

使用例

// 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という名前について

cnclassNameの略です。この命名は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で再利用可能なコンポーネントを作る際に欠かせません。

参考リンク

GitHubで編集を提案

Discussion