AI を活用して Tailwind CSS の設定ファイルを v4 形式へ移行し、生成される CSS の正当性を保証してみた

AI を活用して Tailwind CSS の設定ファイルを v4 形式へ移行し、生成される CSS の正当性を保証してみた

Tailwind CSS v3 から v4 へ更新する際、設定を JS から CSS に移行する `@theme` への切り替え方と、移行前後で見た目が一切変わっていないことを機械的に保証する検証手法を紹介します。
2026.06.27

リテールアプリ共創部のるおんです。

先日、あるアプリケーションで 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.jsthemefontSize / 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 で「設定をどこに書くか」が変わりました。

https://tailwindcss.com/docs/theme

  • 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 をそのまま使えます。

web/src/index.css
@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 設定でこう書いていた色を、

tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: "#2979ff",
        secondary: "#fb8c00",
      },
    },
  },
};

@theme では CSS の変数 として定義します。テーマの定義には新しい @theme ディレクティブを使います。

app.css
@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.jsthemeindex.css@theme に書き写し、@configtailwind.config.js を消す だけです。

ポイントは 値のマッピング です。特に fontSize が曲者でした。v3 の fontSize は「サイズ・行間・ウェイト」を1つのタプルで持てます。

web/tailwind.config.js
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 変数 で表現します。

web/src/index.css
@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-14ptW3bg-extremelyPale)もちゃんと出ています。

「本当に壊れていない」かどうかを保証する

「ビルドが通って、CSS にクラス名も出ている」——これだけで「移行成功!」としてしまうのは、正直かなり危ういです。確認できているのは クラスが生成されたこと だけで、そのクラスが適用する値(px・行間・ウェイト・色)が移行前とまったく同じか は、何も保証できていません。

特に、移行作業を AI に任せた場合はなおさらです。AI は大量のトークンを一気に書き出してくれますが、39個ある fontSize のどれか1つでサイズや行間を取り違えていないか を、人間が目視で1つずつ確かめるのは現実的ではありません。だからこそ、「正しさ」を人間の目視ではなく仕組みで担保する 必要があります。

特に今回の移行で怖かったのは、次の3点です。

  • text-14ptW3font-size だけでなく line-height と font-weight も 移行前どおり全部出すか(companion 変数がちゃんと効くか)
  • bg-extremelyPale のような camelCase の色 が、v4 でも欠落・別名化せずに生成されるか
  • すべての値(px / 行間 / 色 / 影)が 移行前と完全一致 しているか

これらは目視やビルド成否では分かりません。そこで取った方針が、移行前(@config)と移行後(@theme)でそれぞれビルドし、生成された CSS を全カスタムクラスについて実値で diff する という回帰比較です。同じ Tailwind v4 エンジンが、設定の読み込み元(JS の @config か CSS の @theme か)だけ変えて出力するので、出力が一致すれば「見た目は変わっていない」と機械的に言える はずです。

手順

  1. 全カスタムクラス(text-9ptW3text-34ptW6 の39個、全色の bg-*shadow-mediumfont-sans / font-body)を1ファイルに列挙した ダミーファイルsrc に置き、Tailwind にスキャンさせる
  2. 移行後@theme)でビルドして CSS を取得
  3. 移行前@config + tailwind.config.js)に一時的に戻してビルドし、CSS を取得
  4. 2 と 3 の生成 CSS を、クラスごとに 実値 で比較する

生ルールを見比べる

まず、.text-14ptW3 という1クラスの生ルールを、移行前後で並べてみます。

before(@config)
.text-14ptW3{font-size:14px;line-height:var(--tw-leading,19px);font-weight:var(--tw-font-weight,300)}
after(@theme)
.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 に変数が定義されています。

after(@theme の変数定義)
--text-14ptW3:14px;
--text-14ptW3--line-height:19px;
--text-14ptW3--font-weight:300;

さらに、テーマ変数を解決して実値に直すところまでスクリプトに任せます--text-* / --color-* のような テーマ変数(定数)だけを解決し--tw-leading--tw-font-weight のような 実行時に決まる変数はそのまま残す こと。後者は移行前後で同じトークンなので、触らずに比較すれば一致します。

比較スクリプト(省略版)
compare.mjs
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}`);

スクリプトに解決させ、移行前後の宣言を並べた出力がこちらです(抜粋)。

解決後の宣言(before == after)
.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 buildstorybook build がすべて緑であることも確認して、移行完了です。

おわりに

今回は、Tailwind v4 の @config(JS設定の互換モード)から @theme(CSS-first)への完全移行と、その正しさをどう保証したか を書きました。

振り返ると、学びは「AI に任せて速くするほど、出力の正しさは仕組みで保証する」に尽きます。

  • AI に任せた作業ほど、検証を機械化する 。今回は移行そのものを AI に任せて工数を大きく削れたが、速くなったぶん「その出力が正しいか」は人間が担保する 必要がある。しかも目視は非現実的なので、移行前後の出力を機械比較する仕組みに落とすのが現実的
  • 比較は「最終的な実値」で行う 。Tailwindのが生成するClass名だけでなく、変数を解決して移行前後のCSSの 最終値 を突き合わせる。クラス名が出ているかではなく、適用される px・色・ウェイトまで見るのが肝です

「設定を移しただけ」のPRほど、レビューでは見た目の差分が分かりにくく、事故が静かに混入しがちです。とくに移行を AI に任せて速く回すなら、出力の同一性を機械的に取れる形にしておくこと がセットだと感じました。そこまで用意できれば、AI に任せた移行でも安心してマージできます 。同じように大量のデザイントークンを抱えたまま Tailwind v4 の @theme へ移ろうとしている方の参考になれば幸いです。

以上、どなたかの参考になれば幸いです。

参考

https://tailwindcss.com/docs/theme

https://tailwindcss.com/docs/functions-and-directives

https://tailwindcss.com/docs/upgrade-guide

この記事をシェアする

関連記事