← Blog
Tailwind CSSCSSDark ModeMigration

Tailwind CSS v4移行で壊れたもの — @custom-variantとダークモードの実装記録

更新 2026年3月13日14 分で読める

このトピックを深掘りする

TanStack Start × Cloudflare

TanStack StartとCloudflare Pagesを組み合わせたフルスタック構成・Tailwind v4移行

全4本中 3 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

はじめに — v3からv4へ移行した動機

sakimytocomは211個のインタラクティブコンポーネントを持つポートフォリオサイトだ。Tailwind CSS v3で構築していたが、v4のリリースを機に移行を決めた。

移行の動機は3つあった:

  1. CSS-first configuration: tailwind.config.js を捨てて、CSSファイル内で完結する設定体系に移行したかった。設定ファイルとCSSが分離している状態は、コンポーネントが200を超えると管理コストが無視できなくなる
  2. Viteプラグインとしてのネイティブ統合: v4は @tailwindcss/vite プラグインを提供しており、PostCSSの設定が不要になる。Viteのビルドパイプラインがシンプルになる
  3. パフォーマンス: v4のエンジンはRustベースの Lightning CSS を内部で使用しており、ビルド速度の改善が期待できた

結論から言うと、移行自体は1日で完了した。ただし「壊れたもの」は確実に存在した。この記事では、実際に踏んだ破壊的変更と、その解決策を記録する。

壊れたもの1: darkMode設定の廃止

v3の世界

v3では、ダークモードの切替方法を tailwind.config.js で指定していた:

// tailwind.config.js (v3)
module.exports = {
  darkMode: 'class',
  theme: {
    extend: {
      // ...
    },
  },
}

darkMode: 'class' を指定すると、<html> 要素に dark クラスが付与されているときに dark: プレフィックスが有効になる。シンプルで分かりやすい仕組みだった。

v4で何が変わったか

v4では tailwind.config.js 自体が廃止された。JavaScript設定ファイルに書いていた darkMode オプションは消滅し、代わりにCSS側で @custom-variant を使って自分で定義する必要がある。

最初にこの変更を知ったとき、正直「面倒だな」と思った。211コンポーネント全てで dark: プレフィックスを使っている。全部書き換えが必要なのか?

実装: @custom-variant

結果的に、CSSに2行追加するだけで解決した:

/* src/styles/app.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));

@custom-variant はv4で導入された新しいディレクティブで、任意のCSSセレクタをTailwindのバリアントとして登録できる。上記の定義で、dark: バリアントは「.dark クラスを持つ要素、またはその子孫要素」にマッチするようになる。

重要なのは、既存の dark: プレフィックスがそのまま動いたことだ。211コンポーネントのテンプレートを1行も変更する必要がなかった。v4の @custom-variant は、v3の darkMode: 'class' と同じバリアント名 dark を登録しているだけなので、後方互換性が自然に保たれる。

light: バリアントの追加

v3にはなかったメリットもあった。@custom-variant は任意のバリアントを定義できるため、light: バリアントを追加できた:

@custom-variant light (&:where(.light, .light *));

sakimytocomはダークモードがデフォルトだ。v3では「ダークモードでないとき」のスタイルを書くために、dark: を使わない素のクラスとして定義する必要があった。v4では light: バリアントを明示的に使えるため、意図がコード上で明確になる:

/* v3: ダーク用のスタイルだけバリアント付き、ライト用は素のクラス */
.border-color {
  @apply border-gray-200 dark:border-gray-800;
}

/* v4: 両方のモードが明示的 */
@layer base {
  *,
  ::after,
  ::before,
  ::backdrop,
  ::file-selector-button {
    @apply border-gray-800 light:border-gray-200;
  }
}

ダークモードデフォルトのサイトでは、素のクラスがダーク用、light: バリアントがライト用という構造になり、可読性が大幅に改善された。

壊れたもの2: tailwind.config.js の廃止とCSS-first設定

設定ファイルの移行

v3では tailwind.config.js にテーマのカスタマイズ、プラグイン、コンテンツパスなど全てを書いていた:

// tailwind.config.js (v3)
module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        brand: '#6366f1',
      },
      fontFamily: {
        mono: ['JetBrains Mono', 'monospace'],
      },
    },
  },
  plugins: [
    require('@tailwindcss/typography'),
  ],
}

v4ではこれらが全てCSS内のディレクティブに置き換わる:

/* src/styles/app.css (v4) */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));

@theme {
  --color-brand: #6366f1;
  --font-mono: 'JetBrains Mono', monospace;
}

content パスの指定も不要になった。v4はViteプラグインとして動作するため、Viteのモジュールグラフからテンプレートファイルを自動検出する。

Viteプラグインとしての統合

v3ではPostCSSプラグインとして動作していたため、postcss.config.js が必要だった。v4では @tailwindcss/vitevite.config.ts に追加するだけで動作する:

// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [
    tailwindcss(),
    tsConfigPaths({ projects: ['./tsconfig.json'] }),
    cloudflare({ viteEnvironment: { name: 'ssr' } }),
    tanstackStart(),
    react(),
  ],
})

ここで重要なのがプラグインの順序だ。tailwindcss() は必ず最初に配置する。また、Cloudflareプラグインは TanStack Start より前に配置する必要がある。この順序を間違えると、CSSの処理が正しく行われずビルドが壊れる。sakimytocomではこの順序問題で数時間を費やした。

postcss.config.js は削除できた。設定ファイルが1つ減るのは精神的にも良い。

壊れたもの3: ユーティリティクラス名の変更

v4では一部のユーティリティクラス名が変更された。sakimytocomで影響があったものをリストアップする:

v4では一部の名前が変わっている。影響が大きかったものから順に並べる:

  • bg-opacity-50bg-black/50: カラー修飾子に統一
  • ring-opacity-50ring-black/50: 同様
  • flex-shrink-0shrink-0: 短縮形に統一
  • flex-growgrow: 同上
  • overflow-clipoverflow-clip: 変更なし(ただし挙動確認が必要)
  • decoration-clonebox-decoration-clone: プレフィックス追加

特に bg-opacity-* 系の変更は影響範囲が広かった。211コンポーネントのうち、半透明の背景色を使っているものは多い。v4では bg-black/50 のようにカラー値にスラッシュで透明度を指定する形式に統一された。

一括置換で対応したが、bg-opacity の値がCSS変数で動的に設定されているケースもあり、手動確認が必要だった。

{/* v3 */}
<div className="bg-white bg-opacity-10 dark:bg-black dark:bg-opacity-20">

{/* v4 */}
<div className="bg-white/10 dark:bg-black/20">

v4の記法のほうが明らかに簡潔で、意図も読み取りやすい。移行コストはあるが、結果的にはコードの質が上がった。

FOUCとの戦い — SSR + ダークモードデフォルトの罠

sakimytocomはTanStack Startを使ったSSRサイトだ。ダークモードをデフォルトにしている。この組み合わせはFOUC(Flash of Unstyled Content)を引き起こす。

問題の構造

  1. サーバーがHTMLをレンダリングする(ダークモードのスタイルが適用される)
  2. ブラウザがHTMLを受信し、表示を開始する
  3. ユーザーがライトモードを選択していた場合、JavaScriptが読み込まれてReactがハイドレーションされるまで、ダークモードで表示される
  4. ハイドレーション完了後にライトモードに切り替わる → 画面がフラッシュする

この問題はTailwind v3でもv4でも同じだが、v4移行時に改めて対処を確認した。

解決策: インラインスクリプト

__root.tsxhead() にインラインスクリプトを配置し、Reactのハイドレーションより前にテーマクラスを設定する:

// src/routes/__root.tsx
export const Route = createRootRoute({
  head: () => ({
    scripts: [
      {
        children: \\`
          (function() {
            var t = localStorage.getItem('theme');
            if (t === 'light' || (!t && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
              document.documentElement.classList.add('light');
            }
          })();
        \\`,
      },
    ],
  }),
})

このスクリプトのポイント:

  1. 即座に実行される: <head> 内のインラインスクリプトはHTMLパースをブロックするため、<body> のレンダリング前にクラスが設定される
  2. localStorageを参照: ユーザーの過去の選択を尊重する
  3. システム設定のフォールバック: localStorageに値がなければ、prefers-color-scheme メディアクエリを確認する。ダークモードがデフォルトなので、明示的にライトモードを好むユーザーにのみ light クラスを付与する
  4. ダークモードはクラス不要: html 要素にクラスがない状態がダークモード。light クラスがある場合のみライトモードになる

suppressHydrationWarning

このインラインスクリプトは <html> 要素のクラスをReactの管理外で変更する。そのため、サーバーレンダリング時の <html> とクライアント側の <html> でクラスが一致しない可能性がある。Reactは不一致を検出してハイドレーション警告を出す。

suppressHydrationWarning<html> 要素に付与することで、この警告を抑制する。テーマ切替のようにSSR/CSRの不一致が意図的なケースでは、この属性の使用は正当だ。

基盤CSSの設計

v4移行に合わせて、app.css@layer base も整理した:

@layer base {
  *,
  ::after,
  ::before,
  ::backdrop,
  ::file-selector-button {
    @apply border-gray-800 light:border-gray-200;
  }

  html {
    @apply bg-gray-950 text-gray-100;
    color-scheme: dark;
  }

  html.light {
    @apply bg-white text-gray-900;
    color-scheme: light;
  }

  body {
    @apply min-h-screen antialiased;
  }
}

color-scheme プロパティは見落としがちだが重要だ。これを設定しないと、スクロールバーやフォーム要素のデフォルトスタイルがOSのテーマと一致しない。ダークモード時に白いスクロールバーが表示されるのは見た目が悪い。

ボーダーカラーのデフォルトもここで設定している。v3では border-gray-200 がTailwindのデフォルトだったが、ダークモードデフォルトのサイトでは border-gray-800 のほうが自然だ。

移行の成果

設定ファイルの削減

  • tailwind.config.js: 削除
  • postcss.config.js: 削除
  • app.css: @custom-variant@theme で設定を集約

設定が「CSSに閉じた」ことで、スタイルに関する全ての情報が1ファイルに集約された。

@custom-variant による柔軟なテーマ切替

dark:light: の両方のバリアントを明示的に使えるようになったことで、テーマ関連のスタイルの可読性が向上した。特にダークモードデフォルトのサイトでは、light: バリアントの存在が大きい。

ビルド時間

体感レベルだが、開発サーバーのHMRが速くなった。v4のRustベースのエンジンの効果だろう。211コンポーネントを持つプロジェクトでは、CSSの再ビルドが頻繁に発生するため、この改善は開発体験に直結する。

数値で示すと、v3ではHMRに平均200-300msかかっていたのが、v4では100ms前後に改善された印象がある。ただし厳密な計測は行っていないため、参考程度に留めてほしい。

まとめ:Tailwind v4移行の教訓

CSSフレームワークのメジャーバージョンアップは「設定の移行」が本体

211コンポーネントのテンプレートを変更する覚悟で臨んだが、実際に最も時間がかかったのは設定周りの移行だった。tailwind.config.js の廃止、PostCSSからViteプラグインへの移行、@custom-variant の理解。コンポーネントのコード自体は、クラス名の一括置換を除けばほぼ無変更で済んだ。

移行ツールを信用しすぎない

Tailwind公式の移行ガイドとコードモドツールは存在するが、プロジェクト固有の設定(Viteプラグインの順序、Cloudflareとの統合など)はカバーされない。公式ツールで80%は移行できるが、残りの20%が一番時間がかかる。

ダークモードデフォルトは設計時に決めるべき

FOUCの対策、color-scheme の設定、ボーダーカラーのデフォルト値など、ダークモードデフォルトは「後から変更する」と影響範囲が広い。v4移行を機にこの設計を見直せたのは良かったが、本来はプロジェクト開始時に決定すべき事項だ。

v4は「正しい方向」への変更

設定がCSSに閉じる、PostCSSが不要になる、バリアントを自由に定義できる。v4の変更はどれも「CSSフレームワークとしてあるべき姿」に近づいている。移行コストは発生するが、移行後のコードベースは確実にシンプルになった。211コンポーネントを持つプロジェクトでも、移行は1日で完了する。恐れる必要はない。