在 Tailwind v4 里,@theme 中的 --z-overlay: 200 并不会生成 z-overlay 这个工具类。工具类是从 namespace 读取的,而 z- 对应的那个是 --z-index-。 不会报错,也不会有警告。这个 class 就是不存在,元素退回到 z-index: auto,任何带有真实正数 z-index 的东西都会盖在它上面 —— 在某些路由上会,在另一些路由上不会。 真正能一锤定音的检查,是在构建产物的 CSS 里 grep 这个 class 名,而不是在源码里。
症状
我 booking 模板里的移动端 admin 导航。点一下汉堡菜单,抽屉滑进来 —— 然后那层变暗的 backdrop 压在了它上面。抽屉透过遮罩能看见,被染了色也糊了一层,在抽屉里的每一次点击都是关掉菜单,而不是跳转。
桌面端没问题。只在 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,因为这个 token 是我自己写在 src/app/globals.css 里的:
@theme inline {
--z-overlay: 200;
}所以取值本身没什么可怀疑的,我转头去找 stacking context —— 某个祖先上的 transform、backdrop-filter 或 will-change 创建了新的 context,把抽屉的 z-index 困在了里面。对于"我的 z-index 是对的却被忽略了",这通常就是答案,我在这上面实打实地烧掉了不少时间。遮罩上正好挂着 backdrop-blur-sm,让这个方向看起来很合理。
问错了问题。我查过一次抽屉的 computed style,看到 z-index: auto,然后把它读成"stacking context 把它吞了",而不是那个更简单的解释:根本没有规则可以应用。我从来没检查过 z-overlay 到底是不是一个 class。
真正的机制:theme namespace
Tailwind v4 把配置从 tailwind.config.js 挪进了 CSS(@config 作为兼容逃生口保留了下来),而 CSS 变量和工具类之间的桥梁就是 theme namespace。变量名的前缀决定它喂给哪一族工具类:
--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 这个工具类是这样注册的 —— 文件是压缩过的,换行是我自己加的:
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 连候选都算不上 —— 没有工具类可生成。
把每个工具类的同一个字段都抓出来,就能拿到大部分 namespace 清单:
grep -o 'themeKeys:\[[^]]*\]' node_modules/tailwindcss/dist/lib.mjs \
| grep -o -- '--[a-z-]*' | sort -u4.3.2 上是 61 个 namespace。在你把它当成权威之前有两点要注意:更窄的模式 themeKeys:\["--[a-z-]*"\] 只能抓到读取单个 key 的工具类,返回 43 个;另外有几个 namespace —— 最典型的是 --spacing —— 是通过单独的代码路径解析的,压根不会出现在任何 themeKeys 数组里。所以这是个嗅探测试,不是规范。不管怎样,重要的那部分都成立:清单是有限的,它在 build 时就定死了,而 --z-overlay 不在上面。一个前缀不是 namespace 的变量,对工具类引擎来说就只是个变量而已。
不用浏览器你也能亲眼看到它发生,直接用 compiler 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);
}两个变量都输出了。class 只输出了一个。我在那里是故意用 @theme static 的,因为三种模式里就属它最有迷惑性:变量会落在 :root 上,于是你可以在 DevTools 里检查它、确认 --z-overlay: 200,然后得出 token 一切正常的结论 —— 而那个工具类根本不存在。普通的 @theme 会把没用到的变量剪掉,@theme inline —— 我的 globals.css 用的就是这个 —— 变量和 class 一个都不输出。每种模式都是静悄悄的;区别只在于它们递给你多少虚假的安心感。
这个失败模式比"没有 z-index"更糟。一个 z-index: auto 的定位元素不会被提升到任何地方 —— 它按树的顺序,和其他"已定位但未成栈"的元素一起绘制。而带任何正数 z-index 的元素都会在更靠后的一轮里绘制,无条件地压在它们全部之上。所以不管两者各自在 DOM 里的什么位置,z-40 都会赢过一个坏掉的 z-overlay —— 而在那些没有别的元素声明正数 z-index 的路由上,同一个坏掉的 class 看起来又像是好用的,因为树的顺序碰巧对它有利。这就是为什么它读起来像是某个路由特有的渲染怪癖,而不是一个缺失的 class。
修复
改一个变量名,别的什么都没动。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 会把 namespace 前缀从 token 名字上剥掉。分布在 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}坏掉时:exit code 1,没有任何输出。就这一条命令,划清了"token 定义了"和"class 存在"之间的界线。你每引入一个自定义命名的工具类都跑一遍 —— 它同样适用于 bg-*、rounded-*,以及任何你手工 token 化的东西。
最后一点,这也是为什么我只在一个项目里踩到过它。我另外四个模板压根没定义 --z-index-* token:其中三个用 arbitrary value 来叠层 —— z-[90]、z-[100]、z-[250] —— 第四个直接在普通 CSS 里手写 z-index。这两条路都完全绕开了 theme,永远能编译出来。booking 是唯一一个把这些魔法数字提升成具名 token 的,而这也是唯一能踩中 namespace 规则的方式。这还意味着,把一个组件从 booking 复制到另外四个里的任何一个,都会悄悄丢掉它的 z-index:class 名字跟着走了,token 没有。
这就是我在 booking 模板里交付的做法,在线 demo 见 booking.violettadev.com。
在 tailwindcss@4.3.2 和 next@16.2.10 上验证过。