AI を活用して Tailwind CSS の設定ファイルを v4 形式へ移行し、生成される CSS の正当性を保証してみた
リテールアプリ共創部のるおんです。
先日、あるアプリケーションで Tailwind CSS を v3 から v4 へ更新する作業を実施しました。
v4 にすると、それまで tailwind.config.js(JS設定)に書いていたカスタムテーマ(色・フォントサイズなど)を どう持つか で、大きく2つの方法があります。
- 方法1:
@configで JS 設定をそのまま使い続ける(互換モード。既存のtailwind.config.jsを残したまま v4 に乗せる) - 方法2:
@themeで CSS 側にテーマを書く(v4 ネイティブの CSS-first)
方法1は v3 からの移行コストをほぼゼロにできる手堅い選択肢で、無理に動かす必要はありません 。一方、v4 本来の書き味は方法2の @theme にあります。
本記事では、まずこの2つの方法を紹介したうえで、方法1(@configら方法2(@theme)へ移行 したときに工夫したことを書きます。
というのも、この手の「設定を別の書き方に移すだけ」の作業は、地味に怖いです。fontSize のような 1つのトークンに複数の値(サイズ・行間・ウェイト)がぶら下がっている 設定を移し替えると、見た目が以前とまったく同じである保証 が意外と取りづらいのです。ビルドが通っても、生成される CSS の値が1pxでもズレていたら、それは立派なデザイン崩れです。
しかも今回、tailwind.config.js の中身を @theme に書き写す作業の大半は AI(Claude Code)に任せました 。おかげで 工数はかなり削減できた のですが、当然ながら AI が出した移行結果が本当に正しいか は別の話です。むしろ、AI に任せて速くなったぶん、「その出力が正しいことをどう保証するか」が今回いちばん肝心 になりました。
なので、どう移行したか に加えて、AI に任せた移行が「移行前と見た目が一切変わっていない」ことをどう機械的に保証したか を書きます。個人的には後半の検証の話が本題です。
先に結論
今回やったことと、その結果を先にまとめます。
tailwind.config.jsのtheme(fontSize/colors/boxShadow/fontFamily)を、index.cssの@theme { ... }へ移植した@configディレクティブとtailwind.config.jsを撤去し、v4 ネイティブの CSS-first 構成にした- 移行前(
@config)と移行後(@theme)で 生成CSSを全カスタムクラス分ビルドして実値で diff し、差分ゼロ を確認した
| 観点 | 結果 |
|---|---|
tailwind.config.js |
撤去(@theme に集約) |
| ビルド / Storybook / lint / 型チェック | すべて緑 |
| 生成CSSの回帰(全56クラスの実値比較) | 差分0(font-size / line-height / font-weight・色・影・フォントすべて一致) |
「ビルドが通った」で終わらせず、最終的に出力される CSS が1文字単位で同じ意味になっている ところまで確認した、というのが今回の肝です。
Tailwind v4 でのテーマの持ち方
まず、Tailwind v3 と v4 で「設定をどこに書くか」が変わりました。
- v3: 設定は
tailwind.config.js(JS)に書く。Tailwind が起動時に自動でこのファイルを読み込む。 - v4: 思想が CSS-first になり、設定は CSS の中に
@theme { ... }で書くのが標準。そして v4 はtailwind.config.jsを自動では読み込まなくなった 。
そのうえで、v4 でカスタムテーマを持つ方法が、冒頭で触れた2つです。
方法1: @config で JS 設定をそのまま使う(互換モード)
v4 には「昔の JS 設定を読み込む」ための @config ディレクティブが用意されています。CSS にこの1行を書いておくと、v4 でも従来の tailwind.config.js をそのまま使えます。
@import "tailwindcss";
+@config "../tailwind.config.js";
body {
height: 100svh;
}
v3 → v4 のアップグレード時は、これで 330行ほどあるカスタムテーマを一切書き換えずに v4 へ乗せられました。既存のテーマ資産が大きいほどありがたい 手堅い選択肢で、これはこれで十分アリです。急いで @theme に移す必要はありません。
方法2: @theme で CSS にテーマを書く(CSS-first)
一方、v4 本来の書き味は @theme です。tailwind.config.js を捨て、テーマを CSS の @theme { ... } に直接書きます。
たとえば、JS 設定でこう書いていた色を、
module.exports = {
theme: {
extend: {
colors: {
primary: "#2979ff",
secondary: "#fb8c00",
},
},
},
};
@theme では CSS の変数 として定義します。テーマの定義には新しい @theme ディレクティブを使います。
@import "tailwindcss";
@theme {
--color-primary: #2979ff;
--color-secondary: #fb8c00;
}
こうすると、Tailwind が --color-primary から bg-primary / text-primary といったユーティリティを生成してくれるので、これまでと同じようにクラスとして使えます。
<div class="bg-primary text-secondary">Hello, Tailwind CSS!</div>
このように、@theme には、
- 設定が CSS に一元化され、JS 設定ファイルが要らない
- トークンが CSS カスタムプロパティ(
var(--color-primary)など)として公開され、CSS や任意値・実行時から直接参照できる - JS 設定の評価が無くなるぶんビルドも軽い
といった利点があります。
今回は、この方法2へ移ることにしました。といっても方法1が悪いわけではなく、v4 を「本来の形」で持っておきたかった というのが主な動機です。
@theme への移行
やること自体はシンプルで、tailwind.config.js の theme を index.css の @theme に書き写し、@config と tailwind.config.js を消す だけです。
ポイントは 値のマッピング です。特に fontSize が曲者でした。v3 の fontSize は「サイズ・行間・ウェイト」を1つのタプルで持てます。
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
fontSize: {
// [サイズ, { 行間, ウェイト }] の3点セット
"14ptW3": ["14px", { lineHeight: "19px", fontWeight: "300" }],
},
colors: {
primary: { DEFAULT: "#0017C1", touch: "#00118F" },
extremelyPale: "#F8F8FB",
},
},
fontFamily: {
sans: ["Noto Sans JP", "sans-serif"],
},
},
};
これを @theme に移すと、3点セットは 3つの CSS 変数に分解 します。--text-{名前} 本体に対して、行間は --text-{名前}--line-height、ウェイトは --text-{名前}--font-weight という companion 変数 で表現します。
@import "tailwindcss";
@theme {
/* フォントサイズ(末尾 W3=ウェイト300 / W6=600。行間とウェイトを同梱) */
--text-14ptW3: 14px;
--text-14ptW3--line-height: 19px;
--text-14ptW3--font-weight: 300;
/* カラー(ネストは DEFAULT を省き、サブキーはハイフンで繋ぐ) */
--color-primary: #0017C1;
--color-primary-touch: #00118F;
--color-extremelyPale: #F8F8FB;
/* フォントファミリ */
--font-sans: "Noto Sans JP", sans-serif;
}
body {
height: 100svh;
}
マッピングのルールを整理すると、こうなります。
| JS設定(v3形式) | @theme(v4形式) |
|---|---|
fontSize["14ptW3"] = ["14px", { lineHeight, fontWeight }] |
--text-14ptW3 / --text-14ptW3--line-height / --text-14ptW3--font-weight |
colors.primary.DEFAULT / colors.primary.touch |
--color-primary / --color-primary-touch |
colors.extremelyPale(camelCase) |
--color-extremelyPale(camelCase のまま) |
boxShadow.medium |
--shadow-medium |
fontFamily.sans |
--font-sans |
今回はトークンが39個(fontSize)+色13個+影+フォント、と数が多かったので、手で書き写すと必ずどこかでミスる と判断し、tailwind.config.js を読み込んで @theme ブロックを吐く小さな生成スクリプトを書いて変換しました。地味ですが、機械にやらせられるところは機械にやらせるのが安全です。
あとは index.css から @config "../tailwind.config.js"; を消し、tailwind.config.js を削除すれば移行完了です。content(スキャン対象)の指定も、v4 は自動検出してくれるので不要になりました。
ここまでで vite build も Storybook のビルドも通り、生成された CSS にカスタムクラス(text-14ptW3 や bg-extremelyPale)もちゃんと出ています。
「本当に壊れていない」かどうかを保証する
「ビルドが通って、CSS にクラス名も出ている」——これだけで「移行成功!」としてしまうのは、正直かなり危ういです。確認できているのは クラスが生成されたこと だけで、そのクラスが適用する値(px・行間・ウェイト・色)が移行前とまったく同じか は、何も保証できていません。
特に、移行作業を AI に任せた場合はなおさらです。AI は大量のトークンを一気に書き出してくれますが、39個ある fontSize のどれか1つでサイズや行間を取り違えていないか を、人間が目視で1つずつ確かめるのは現実的ではありません。だからこそ、「正しさ」を人間の目視ではなく仕組みで担保する 必要があります。
特に今回の移行で怖かったのは、次の3点です。
text-14ptW3が font-size だけでなく line-height と font-weight も 移行前どおり全部出すか(companion 変数がちゃんと効くか)bg-extremelyPaleのような camelCase の色 が、v4 でも欠落・別名化せずに生成されるか- すべての値(px / 行間 / 色 / 影)が 移行前と完全一致 しているか
これらは目視やビルド成否では分かりません。そこで取った方針が、移行前(@config)と移行後(@theme)でそれぞれビルドし、生成された CSS を全カスタムクラスについて実値で diff する という回帰比較です。同じ Tailwind v4 エンジンが、設定の読み込み元(JS の @config か CSS の @theme か)だけ変えて出力するので、出力が一致すれば「見た目は変わっていない」と機械的に言える はずです。
手順
- 全カスタムクラス(
text-9ptW3〜text-34ptW6の39個、全色のbg-*、shadow-medium、font-sans/font-body)を1ファイルに列挙した ダミーファイル をsrcに置き、Tailwind にスキャンさせる - 移行後(
@theme)でビルドして CSS を取得 - 移行前(
@config+tailwind.config.js)に一時的に戻してビルドし、CSS を取得 - 2 と 3 の生成 CSS を、クラスごとに 実値 で比較する
生ルールを見比べる
まず、.text-14ptW3 という1クラスの生ルールを、移行前後で並べてみます。
.text-14ptW3{font-size:14px;line-height:var(--tw-leading,19px);font-weight:var(--tw-font-weight,300)}
.text-14ptW3{font-size:var(--text-14ptW3);line-height:var(--tw-leading,var(--text-14ptW3--line-height));font-weight:var(--tw-font-weight,var(--text-14ptW3--font-weight))}
そして @theme 側は、:root に変数が定義されています。
--text-14ptW3:14px;
--text-14ptW3--line-height:19px;
--text-14ptW3--font-weight:300;
さらに、テーマ変数を解決して実値に直すところまでスクリプトに任せます 。--text-* / --color-* のような テーマ変数(定数)だけを解決し 、--tw-leading や --tw-font-weight のような 実行時に決まる変数はそのまま残す こと。後者は移行前後で同じトークンなので、触らずに比較すれば一致します。
比較スクリプト(省略版)
import fs from "fs";
// 比較対象の全カスタムクラス(プローブで列挙したものと同じ)
const classes = ["text-9ptW3", "text-14ptW3", "text-14ptW6", /* ...省略... */ "bg-primary", "bg-extremelyPale", "shadow-medium", "font-sans", "font-body"];
// :root 等に定義されたテーマ変数(--text-/--color-/...)だけを集める。--tw-* は対象外
function themeVars(css) {
const map = {};
const re = /(--(?:text|color|shadow|font|leading)-[\w-]+)\s*:\s*([^;{}]+)/g;
for (let m; (m = re.exec(css)); ) map[m[1].trim()] = m[2].trim();
return map;
}
// 値の中の「テーマ var()」だけを最内から解決する(--tw-* はそのまま残す)
function resolveTheme(value, vars) {
const re = /var\((--(?:text|color|shadow|font|leading)-[\w-]+)\s*(?:,\s*([^()]*?))?\)/;
for (let m; (m = value.match(re)); ) {
const replaced = vars[m[1]] ?? m[2] ?? m[0];
value = value.slice(0, m.index) + replaced + value.slice(m.index + m[0].length);
}
return value;
}
// あるクラスの宣言を「プロパティ:解決後の値」に正規化して取り出す
function decls(css, cls, vars) {
const re = new RegExp("\\." + cls + "\\{([^}]*)\\}", "g");
const props = {};
for (let m; (m = re.exec(css)); ) {
for (const d of m[1].split(";")) {
const i = d.indexOf(":");
if (i < 0) continue;
props[d.slice(0, i).trim()] = resolveTheme(d.slice(i + 1).trim(), vars);
}
}
return Object.keys(props).sort().map((k) => `${k}:${props[k]}`).join(";");
}
const before = fs.readFileSync("before.css", "utf8");
const after = fs.readFileSync("after.css", "utf8");
const vb = themeVars(before), va = themeVars(after);
let diff = 0;
for (const c of classes) {
if (decls(before, c, vb) !== decls(after, c, va)) {
diff++;
console.log(`[差分] ${c}`);
}
}
console.log(`全${classes.length}クラス / 実値差分: ${diff}`);
スクリプトに解決させ、移行前後の宣言を並べた出力がこちらです(抜粋)。
.text-14ptW3
before(@config): font-size: 14px; font-weight: var(--tw-font-weight,300); line-height: var(--tw-leading,19px)
after (@theme) : font-size: 14px; font-weight: var(--tw-font-weight,300); line-height: var(--tw-leading,19px)
.text-14ptW6
before(@config): font-size: 14px; font-weight: var(--tw-font-weight,600); line-height: var(--tw-leading,19px)
after (@theme) : font-size: 14px; font-weight: var(--tw-font-weight,600); line-height: var(--tw-leading,19px)
.bg-extremelyPale
before(@config): background-color: #f8f8fb
after (@theme) : background-color: #f8f8fb
.font-sans
before(@config): font-family: Noto Sans JP,sans-serif
after (@theme) : font-family: Noto Sans JP,sans-serif
@config 側と @theme 側で、解決後の宣言が1文字単位で一致 しています。text-14ptW3(W3=300)と text-14ptW6(W6=600)でウェイトもきちんと出し分けられており、色(camelCase の bg-extremelyPale 含む)もフォントも同じ。@theme 側はテーマ変数を1枚噛ませているだけで、最終的な値は変わらない、というわけです。
結果:全56クラスで差分ゼロ
この実値比較を全56クラスについて行った結果がこちらです。
=== 全56クラス / 実値差分: 0 ===
font-size / line-height / font-weight(W3=300・W6=600)も、全カスタム色も、shadow-medium も、font-sans / font-body も、移行前と完全に一致 。気にしていた camelCase の色(bg-extremelyPale など)も、欠落・別名化せずに同じ値で生成されていることを確認できました。
最後に、pnpm run validate(lint / 型チェック / フォーマット / cspell)・ユニットテスト・vite build・storybook build がすべて緑であることも確認して、移行完了です。
おわりに
今回は、Tailwind v4 の @config(JS設定の互換モード)から @theme(CSS-first)への完全移行と、その正しさをどう保証したか を書きました。
振り返ると、学びは「AI に任せて速くするほど、出力の正しさは仕組みで保証する」に尽きます。
- AI に任せた作業ほど、検証を機械化する 。今回は移行そのものを AI に任せて工数を大きく削れたが、速くなったぶん「その出力が正しいか」は人間が担保する 必要がある。しかも目視は非現実的なので、移行前後の出力を機械比較する仕組みに落とすのが現実的
- 比較は「最終的な実値」で行う 。Tailwindのが生成するClass名だけでなく、変数を解決して移行前後のCSSの 最終値 を突き合わせる。クラス名が出ているかではなく、適用される px・色・ウェイトまで見るのが肝です
「設定を移しただけ」のPRほど、レビューでは見た目の差分が分かりにくく、事故が静かに混入しがちです。とくに移行を AI に任せて速く回すなら、出力の同一性を機械的に取れる形にしておくこと がセットだと感じました。そこまで用意できれば、AI に任せた移行でも安心してマージできます 。同じように大量のデザイントークンを抱えたまま Tailwind v4 の @theme へ移ろうとしている方の参考になれば幸いです。
以上、どなたかの参考になれば幸いです。
参考










