Tailwind CSS v4移行で壊れたもの — @custom-variantとダークモードの実装記録
このトピックを深掘りする
TanStack Start × Cloudflare
TanStack StartとCloudflare Pagesを組み合わせたフルスタック構成・Tailwind v4移行
全4本中 3 本目。
次に読む記事
IndexNow × Cloudflare Pages — sitemap.xml を即時インデックス通知する運用
sitemap.xml をソースに IndexNow へ一括送信する実装と運用。Cloudflare Pages のデプロイ後に URL を確実に通知して、新規記事の発見を最短化する。
Next.jsを選ばなかった理由 — TanStack Startを選んだ判断基準
203個のインタラクティブコンポーネントを載せるポートフォリオで、Next.jsではなくTanStack Start + Cloudflare Pagesを選んだ技術選定の記録。
TanStack Start × Cloudflare Pages — 詰まった全記録と解決策
CF PagesにTanStack Start v1をデプロイする過程で踏んだ5つの地雷 — Viteプラグイン順序、ServerFnバグ、FOUC対策など — の回避策を実コードで解説する。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
はじめに — v3からv4へ移行した動機
sakimytocomは211個のインタラクティブコンポーネントを持つポートフォリオサイトだ。Tailwind CSS v3で構築していたが、v4のリリースを機に移行を決めた。
移行の動機は3つあった:
- CSS-first configuration:
tailwind.config.jsを捨てて、CSSファイル内で完結する設定体系に移行したかった。設定ファイルとCSSが分離している状態は、コンポーネントが200を超えると管理コストが無視できなくなる - Viteプラグインとしてのネイティブ統合: v4は
@tailwindcss/viteプラグインを提供しており、PostCSSの設定が不要になる。Viteのビルドパイプラインがシンプルになる - パフォーマンス: 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/vite を vite.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-50→bg-black/50: カラー修飾子に統一ring-opacity-50→ring-black/50: 同様flex-shrink-0→shrink-0: 短縮形に統一flex-grow→grow: 同上overflow-clip→overflow-clip: 変更なし(ただし挙動確認が必要)decoration-clone→box-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)を引き起こす。
問題の構造
- サーバーがHTMLをレンダリングする(ダークモードのスタイルが適用される)
- ブラウザがHTMLを受信し、表示を開始する
- ユーザーがライトモードを選択していた場合、JavaScriptが読み込まれてReactがハイドレーションされるまで、ダークモードで表示される
- ハイドレーション完了後にライトモードに切り替わる → 画面がフラッシュする
この問題はTailwind v3でもv4でも同じだが、v4移行時に改めて対処を確認した。
解決策: インラインスクリプト
__root.tsx の head() にインラインスクリプトを配置し、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');
}
})();
\\`,
},
],
}),
})このスクリプトのポイント:
- 即座に実行される:
<head>内のインラインスクリプトはHTMLパースをブロックするため、<body>のレンダリング前にクラスが設定される - localStorageを参照: ユーザーの過去の選択を尊重する
- システム設定のフォールバック: localStorageに値がなければ、
prefers-color-schemeメディアクエリを確認する。ダークモードがデフォルトなので、明示的にライトモードを好むユーザーにのみlightクラスを付与する - ダークモードはクラス不要:
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日で完了する。恐れる必要はない。