Tailwind v4 では、@theme の中に --z-overlay: 200 と書いても z-overlay ユーティリティは作られない。ユーティリティが値を読むのは名前空間からで、z- が見るのは --z-index- だ。 エラーも警告も出ない。クラスが単に存在せず、要素は z-index: auto にフォールバックし、正の z-index を持つものが上に描画される — しかも一部のルートだけで起きて、他では起きない。 決着をつけられるチェックは、ソースではなくビルド後の CSS をクラス名で grep することだ。
症状
自分の booking テンプレートの、管理画面モバイルナビ。ハンバーガーをタップするとドロワーがスライドインしてくる — そしてその上に暗転用のバックドロップが乗る。ドロワーはスクリム越しに色がついてぼやけた状態で見えていて、中をタップするたびに遷移ではなくメニューが閉じる。
デスクトップは問題なし。md ブレークポイントより下だけ。build は通り、lint も通り、テストも通った。
どちらの要素も同じファイル src/components/admin/AdminSidebar.tsx の中に、数十行離れて置かれている:
// line 221 — the drawer panel
"fixed inset-y-0 start-0 z-overlay w-64 -translate-x-full transition-all duration-200 ease-out",
"md:static md:translate-x-0 md:z-auto",// line 248 — the backdrop
className="md:hidden fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"200 は 40 より大きい。ドロワーが勝つはずだ。
自分が思い込んでいたこと
z-overlay は 200 に解決されると思い込んでいた。トークンを src/app/globals.css に書いたのは自分だからだ:
@theme inline {
--z-overlay: 200;
}だから値は疑わず、代わりに stacking context を探しに行った — どこかの祖先の transform、backdrop-filter、will-change が新しいコンテキストを作り、ドロワーの z-index をその中に閉じ込めている、という筋書きだ。「z-index は正しいのに無視される」に対する定番の答えで、そこそこの時間を溶かした。スクリムに backdrop-blur-sm がそのまま乗っていたので、もっともらしく見えた。
問いが間違っていた。一度ドロワーの computed style を見て z-index: auto を確認していたのに、それを「stacking context に飲まれている」と読んでしまい、もっと単純な説明 — 適用されるルールがそもそも無かった — にはたどり着かなかった。z-overlay がそもそもクラスなのかどうかを、一度も確認していなかった。
本当の仕組み: テーマ名前空間
Tailwind v4 は設定を tailwind.config.js から CSS へ移し(@config は互換用の逃げ道として残っている)、CSS 変数とユーティリティをつなぐ橋になるのがテーマ名前空間だ。変数名のプレフィックスが、どのユーティリティ族に値を供給するかを決める:
--color-*→bg-*,text-*,border-*,fill-*, …--spacing-*→p-*,m-*,gap-*,w-*, …--radius-*→rounded-*--tracking-*→tracking-*--z-index-*→z-*
これはインストール済みのパッケージから直接読み取れる。node_modules/tailwindcss/dist/lib.mjs(v4.3.2)では、z ユーティリティはこう登録されている — ファイルは minify されているので、改行は自分で入れたものだ:
n("z",{supportsNegative:!0,handleBareValue:({value:a})=>I(a)?a:null,
themeKeys:["--z-index"],handle:a=>[o("z-index",a)],
staticValues:{auto:[o("z-index","auto")]}})themeKeys: ["--z-index"]。この配列が、z-* が参照する変数プレフィックスの完全なリストだ。--z-overlay はそこに入っていないので、z-overlay は候補ですらない — 生成すべきユーティリティが存在しない。
全ユーティリティから同じフィールドを抜き出せば、名前空間の一覧がだいたい手に入る:
grep -o 'themeKeys:\[[^]]*\]' node_modules/tailwindcss/dist/lib.mjs \
| grep -o -- '--[a-z-]*' | sort -u4.3.2 では 61 個の名前空間。これを正典として扱う前に注意が 2 つある: より狭いパターン themeKeys:\["--[a-z-]*"\] はキーを 1 つしか読まないユーティリティしか拾わず 43 個になる。そしていくつかの名前空間 — 特に --spacing — は別のコードパスで解決されるので、themeKeys 配列には一度も現れない。つまりこれは仕様ではなく、匂いを嗅ぐためのテストだ。肝心な部分はどちらにせよ変わらない: リストは有限で、ビルド時に決まっていて、--z-overlay はそこに載っていない。プレフィックスが名前空間でない変数は、ユーティリティエンジンから見ればただの変数でしかない。
ブラウザなしでも、コンパイラ API を直接叩けば同じことを目で見られる:
import { compile } from 'tailwindcss'
const css = `@theme static {
--z-overlay: 200;
--z-index-modal: 300;
}
@tailwind utilities;`
const c = await compile(css, { base: process.cwd() })
console.log(c.build(['z-overlay', 'z-modal']))出力:
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
:root, :host {
--z-overlay: 200;
--z-index-modal: 300;
}
.z-modal {
z-index: var(--z-index-modal);
}変数は両方とも出力される。クラスは片方だけだ。ここで @theme static をわざと使ったのは、3 つのモードの中でこれがいちばん人を欺くからだ: 変数は :root に出るので、DevTools で覗いて --z-overlay: 200 を確認し、ユーティリティが存在しないのにトークンは健在だと結論できてしまう。素の @theme は使われていない変数を刈り取り、@theme inline — 自分の globals.css が使っているのはこれだ — は変数もクラスも出さない。どのモードも黙っている。違うのは、どれだけ偽の安心感を渡してくるかだけだ。
壊れ方は「z-index が無い」より悪い。z-index: auto の positioned な要素はどこにも持ち上げられず、他の positioned だが stacking されていない要素と一緒にツリー順で描画される。正の z-index を持つ要素は後のパスで、無条件にそれら全部の上に描画される。だから z-40 は、DOM 上でどちらがどこにあろうと壊れた z-overlay に勝つ — そして正の z-index を宣言する要素が他にないルートでは、同じ壊れたクラスが動いているように見える。たまたまツリー順が味方しているだけだ。だからこれは、クラスの欠落ではなくルート固有の描画のクセに見えた。
修正
変数のリネームだけ、それ以外は何もなし。src/app/globals.css:
@theme inline {
/* ── Z-index overlay layer ─────────────────────────────────────────── */
/* Tailwind v4 derives the `z-overlay` utility from the --z-index-* namespace,
so this MUST be --z-index-overlay (a plain --z-overlay generates no class). */
--z-index-overlay: 200; /* z-overlay — modals, dropdowns, popovers */
}ユーティリティ名は z-overlay のままだ。Tailwind がトークン名から名前空間のプレフィックスを剥がすからだ。17 コンポーネントにまたがる 22 箇所の呼び出し — Dialog, Select, Tooltip, DropdownMenu, CommandPalette, Lightbox, LandingHeader — はすでに全部正しく、編集は要らなかった。
どうすれば実際に捕まえられるか
ソースを grep しても何もわからない: z-overlay は解決されようがされまいが 17 ファイルに存在する。grep すべきはビルド出力だ:
pnpm build
grep -o '\.z-overlay{[^}]*}' .next/static/chunks/*.css動いている場合:
.next/static/chunks/2qvhgub5q_n5s.css:.z-overlay{z-index:200}壊れている場合: 終了コード 1、出力なし。この 1 行のコマンドが、「トークンは定義されている」と「クラスが存在する」の差だ。自分で導入した独自名のユーティリティには全部これを走らせるといい — bg-*、rounded-*、手でトークン化したものすべてに一般化できる。
最後にもう 1 つ、これに引っかかったのが 1 プロジェクトだけだった理由だ。他の 4 つのテンプレートは --z-index-* トークンを一切定義していない: 3 つは任意値で重ねていて — z-[90]、z-[100]、z-[250] — 残り 1 つは素の CSS で z-index を手書きしている。どちらの経路もテーマを完全に迂回するので、常にコンパイルされる。マジックナンバーを名前付きトークンに昇格させたのは booking だけで、名前空間のルールに引っかかる唯一の道がそれだった。ということは、booking からコンポーネントを他の 4 つのどれかにコピーすると、その z-index は黙って落ちるということでもある: クラス名は付いていくが、トークンは付いていかない。
これは booking テンプレートで出荷しているパターンで、ライブデモは booking.violettadev.com にある。
tailwindcss@4.3.2 と next@16.2.10 で検証済み。