ReactでTailwind CSSのクラスを条件で切り替える方法

​​React​clsx,Tailwind CSS,tailwind-merge,TypeScript

Tailwind CSS のクラスを props や state で切り替えるときは、bg-${color}-600 のようにクラス名を組み立てず、使うクラス名を全部そのまま書いておきます。条件で足したり外したりするクラスは clsx でまとめ、コンポーネントの外から渡したクラスと中のクラスがぶつかるときは tailwind-merge で片方を残します。タブが選ばれているかどうかといった状態は、aria-selected: のような variant に任せると、JavaScript の条件分岐を減らせます。

筆者は検証環境で、書き方ごとの小さなコンポーネントを作りました。Tailwind CSS でビルドした CSS に何が入るかを調べ、Chromium で開いて、要素に付いたクラスと実際に効いているスタイルを Playwright で読み取りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、tailwindcss と @tailwindcss/cli 4.3.3、clsx 2.1.1、tailwind-merge 3.7.0、typescript 7.0.2、esbuild 0.28.2 です。CSS は Tailwind CSS の CLI で作り、.tsx は tsc で型チェックしてから esbuild で変換しました。ブラウザは Chromium 153 を Playwright 1.63.0 で操作しています。Vite や Next.js のプラグインを使う構成、Tailwind CSS v3、Firefox と Safari は試していません。

クラス名を組み立てると、CSS が作られない

色を props で受け取り、そのままクラス名に埋め込むバッジを作りました。

tsx
import type { ReactNode } from 'react';

type Color = 'violet' | 'emerald';

// 色の名前をつなげてクラス名を作る(Tailwind からは見つけられない)
export function BadBadge({ color, children }: { color: Color; children: ReactNode }) {
  return <span className={`bg-${color}-600 rounded px-2 py-1 text-white`}>{children}</span>;
}

ブラウザで開くと、要素には bg-violet-600 というクラスが付いています。それでも背景は透明のままで、白い文字だけが残りました。ビルドした CSS を調べると、.bg-violet-600 と .bg-emerald-600 のルールがありません。

text
bg-violet-600: 0
bg-emerald-600: 0
bg-sky-600: 1
bg-rose-600: 1
bg-amber-300: 1
text
violet {"className":"bg-violet-600 rounded px-2 py-1 text-white","background":"rgba(0, 0, 0, 0)", ...}

Tailwind CSS は、ソースファイルをただの文字列として読み、クラス名らしい文字の並びを探して CSS を作ります。コードとして実行するわけではないので、テンプレート文字列でつないだ結果の bg-violet-600 は見つけられません。このことは、公式ドキュメントのクラスの検出のしくみと、動的なクラス名についての説明に書かれています。

灰色の枠の中に2種類のバッジを並べた画面。上の「クラス名をつなげて作ったバッジ」は背景が付かず、白い文字の violet と emerald だけが見える。下の「対応表から選んだバッジ」は sky、rose、amber の3つに、それぞれ青、赤、黄色の背景が付いている
クラス名を組み立てたバッジ(上)と、対応表から選んだバッジ(下)

値ごとのクラスを、対応表に全部書いておく

公式ドキュメントが勧めているのは、props の値と、省略しないクラス名を対応させる書き方です。TypeScript では、対応表のキーから色の型を作ると、表にない色を渡したときに型エラーで気づけます。

tsx
import type { ReactNode } from 'react';

// クラス名は、省略せずに全部書いておく
const colorClasses = {
  sky: 'bg-sky-600 text-white',
  rose: 'bg-rose-600 text-white',
  amber: 'bg-amber-300 text-black',
} satisfies Record<string, string>;

type Color = keyof typeof colorClasses;

export function Badge({ color, children }: { color: Color; children: ReactNode }) {
  return <span className={`${colorClasses[color]} rounded px-2 py-1`}>{children}</span>;
}

この書き方では、3色とも CSS が作られ、背景も付きました。値ごとに文字の色を変える(amber だけ黒にする)ことも、表の中で書けます。表にない violet を渡すと、tsc は次のエラーを出しました。

text
check/BadUsage.tsx(4,33): error TS2322: Type '"violet"' is not assignable to type '"amber" | "rose" | "sky"'.

クラス名がソースの中になく、API から届く値などで決まる場合は、CSS の側で @source inline() を使うと、指定したクラスを必ず作らせることができます。公式ドキュメントの特定のクラスを必ず生成させる方法にある書き方です。検証環境では、次の指定で .bg-violet-600 と .bg-emerald-600 が作られました。

css
@import "tailwindcss" source(none);
@source "../src";
@source inline("bg-{violet,emerald}-600");

条件で足し引きするクラスは clsx でまとめる

選ばれているときだけ背景を付ける、件数があれば太字にする、のように条件でクラスを足すとき、テンプレート文字列に && をそのまま入れると、条件に合わなかった値が文字列になって残ります。

tsx
<span className={`rounded-full border px-3 py-1 ${selected && 'bg-sky-600 text-white'} ${count && 'font-bold'}`}>
  {label}({count})
</span>

selected が false、count が 0 のとき、要素のクラスは次のようになりました。

text
"className":"rounded-full border px-3 py-1 false 0"

今回の設定には false や 0 という名前のクラスがないので、見た目は変わりません。ただ、意図しない文字列がクラス名に入っています。clsx を使うと、false、0、null、undefined、空文字は捨てられ、残ったクラスだけが空白でつながります。Node.js で clsx('a', false, 0, null, undefined, '', 'b') を呼ぶと、結果は "a b" でした。

tsx
import { clsx } from 'clsx';

type ChipProps = { label: string; selected: boolean; count: number; disabled?: boolean };

// clsx で、条件に合うクラスだけを残す
export function Chip({ label, selected, count, disabled = false }: ChipProps) {
  return (
    <span
      className={clsx(
        'rounded-full border px-3 py-1',
        selected ? 'border-sky-600 bg-sky-600 text-white' : 'border-gray-300 bg-white text-gray-700',
        count > 0 && 'font-bold',
        disabled && 'opacity-50',
      )}
    >
      {label}({count})
    </span>
  );
}

clsx の中でも、クラス名は省略せずに書いています。clsx は文字列をつなぐだけなので、'bg-' + color のような書き方をすれば、最初の節と同じく CSS は作られません。

text
未選択(0) {"className":"rounded-full border px-3 py-1 border-gray-300 bg-white text-gray-700", ...}
選択中(3) {"className":"rounded-full border px-3 py-1 border-sky-600 bg-sky-600 text-white font-bold", ...}
使えない(2) {"className":"rounded-full border px-3 py-1 border-gray-300 bg-white text-gray-700 font-bold opacity-50", ...}

外から渡したクラスがぶつかるときは tailwind-merge を使う

ボタンのコンポーネントに className を受け取らせ、使う側で色と余白を変えたいことがあります。中のクラスの後ろに外のクラスをつなげるだけだと、後ろに書いたほうが勝つとは限りません。

tsx
import type { ComponentProps } from 'react';
import { clsx } from 'clsx';

// 外から渡されたクラスを、後ろにつなげるだけ
export function JoinButton({ className, ...props }: ComponentProps<'button'>) {
  return <button {...props} className={clsx('rounded bg-sky-600 px-4 py-2 text-white', className)} />;
}

<JoinButton className="bg-rose-600 p-2"> と書いて表示すると、背景は青のまま、左右の余白も 16px のままでした。

text
join {"className":"rounded bg-sky-600 px-4 py-2 text-white bg-rose-600 p-2","background":"oklch(0.588 0.158 241.966)","padding":"8px 16px", ...}

同じ CSS プロパティを指定するクラスが2つ付いていると、効くのは class 属性で後ろにあるほうではなく、CSS ファイルの中で後ろにあるほうです(公式ドキュメントのクラスどうしがぶつかるときの説明)。ビルドした CSS では、.bg-rose-600 が .bg-sky-600 より前に、.p-2 が .px-4 と .py-2 より前に並んでいました。

tailwind-merge の twMerge は、同じ役割のクラスがぶつかったときに、後ろに書かれたクラスを残して前のクラスを消します。

ただ、tailwind-merge のドキュメントの使いどころの説明では、tailwind-merge は逃げ道として使い、ぶつかるクラスを外から渡さない作りを先に考えるよう勧めています。理由として、バンドルが大きくなること(ドキュメントの記載では minify と gzip のあとで約 7kB)や、使う側に自由を与えすぎることが挙げられています。コンポーネントの中のクラスだけを組み合わせるときは、ぶつからないように書き、つなぐだけにします。ドキュメントではそのために tailwind-merge の twJoin を勧めていますが、この記事の例では clsx でつないでいます。

それでも外からクラスを渡せるようにするなら、clsx と組み合わせた関数を1つ作っておくと、条件の書き方はそのままで使えます。

ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

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

ボタンの中で cn('rounded bg-sky-600 px-4 py-2 text-white', className) にすると、外から渡した bg-rose-600 p-2 が効きました。bg-sky-600、px-4、py-2 はクラスから消えています。

text
merge {"className":"rounded text-white bg-rose-600 p-2","background":"oklch(0.586 0.253 17.585)","padding":"8px", ...}
2つの「削除する」ボタンを縦に並べた画面。上の「つなげるだけ(clsx)」は、赤を指定したのに青い背景のままで、左右の余白も広い。下の「ぶつかるクラスを置き換える(cn)」は、赤い背景で余白が狭くなっている
外から bg-rose-600 p-2 を渡したボタン(上はつなげるだけ、下は tailwind-merge を通したもの)

消えるのは、同じ役割のクラスだけです。px-6 だけを渡すと px-4 だけが消えて py-2 は残り、hover:bg-sky-700 を渡しても bg-sky-600 は残りました。

text
twMerge(px-6 だけ): rounded bg-sky-600 py-2 text-white px-6
twMerge(hover は別扱い): rounded bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
注意

@theme で独自の値を足したときは、tailwind-merge にも同じ名前を教えます。検証では --text-huge(文字の大きさ)を足し、text-huge text-amber-300 を渡しました。設定なしの twMerge は text-huge を文字の色だと見なし、text-amber-300 だけを残して text-huge を消しました。次のように extendTailwindMerge で text: ['huge'] を足した twMerge を cn の中で使うと、両方が残り、文字は 32px で表示されました。

ts
import { clsx, type ClassValue } from 'clsx';
import { extendTailwindMerge } from 'tailwind-merge';

// @theme に足した --text-huge を、文字の大きさとして教える
const twMerge = extendTailwindMerge({
  extend: {
    theme: {
      text: ['huge'],
    },
  },
});

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
text
twMerge(設定なし): rounded bg-sky-600 px-4 py-2 text-amber-300
twMerge(text に huge を追加): rounded bg-sky-600 px-4 py-2 text-huge text-amber-300

設定が必要な名前の一覧は、tailwind-merge の設定のドキュメントにある Theme の節にあります。独自の色(--color-*)は設定しなくても扱えると書かれています。

状態で変わる見た目は、aria-selected: に任せる

タブのように、選ばれているかどうかで見た目が変わる部品は、状態を aria-selected 属性に出しておけば、クラスの切り替えを Tailwind CSS の variant に任せられます。aria-selected: を付けたクラスは、aria-selected="true" の要素にだけ効きます(公式ドキュメントのARIA 属性で見た目を変える variant)。

tsx
import { useState } from 'react';

const tabs = ['概要', '設定', '履歴'];

// 選ばれているかどうかは aria-selected に出し、見た目は aria-selected: で切り替える
export function Tabs() {
  const [current, setCurrent] = useState(0);
  return (
    <div role="tablist" className="flex gap-2 border-b border-gray-200">
      {tabs.map((tab, i) => (
        <button
          key={tab}
          role="tab"
          aria-selected={i === current}
          onClick={() => setCurrent(i)}
          className="border-b-2 border-transparent px-3 py-2 text-gray-500 aria-selected:border-sky-600 aria-selected:text-sky-700"
        >
          {tab}
        </button>
      ))}
    </div>
  );
}

className は固定の文字列なので、クラス名を組み立てる心配もありません。ビルドした CSS には .aria-selected\:border-sky-600[aria-selected="true"] というルールが作られました。「設定」をクリックすると、下線の色が「概要」から「設定」に移りました。

text
「設定」をクリック
概要 {"selected":"false","border":"rgba(0, 0, 0, 0)","color":"oklch(0.551 0.027 264.364)"}
設定 {"selected":"true","border":"oklch(0.588 0.158 241.966)","color":"oklch(0.5 0.134 242.749)"}
履歴 {"selected":"false","border":"rgba(0, 0, 0, 0)","color":"oklch(0.551 0.027 264.364)"}

選ばれているかどうかは aria-selected の1か所で決まるので、属性と見た目がずれません。aria-pressed や aria-expanded などの一覧と、data-* 属性で切り替える variant の説明が、公式ドキュメントの同じページにあります。

​Posted by 86