In Tailwind v4, --z-overlay: 200 inside @theme does not create a z-overlay utility. Utilities read from namespaces, and the one for z- is --z-index-. Nothing errors and nothing warns. The class simply does not exist, the element falls back to z-index: auto, and anything with a real positive z-index paints over it — on some routes, not others. The check that actually settles it is grepping the built CSS for the class name, not the source.
The symptom
Mobile admin nav in my booking template. Tap the hamburger, the drawer slides in — and the dimming backdrop sits on top of it. The drawer is visible through the scrim, tinted and blurred, and every tap inside it closes the menu instead of navigating.
Desktop was fine. Only below the md breakpoint. Build passed, lint passed, tests passed.
Both elements live in the same file, src/components/admin/AdminSidebar.tsx, a couple dozen lines apart:
// 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 is greater than 40. The drawer should win.
What I assumed
I assumed z-overlay resolved to 200, because I had written the token myself in src/app/globals.css:
@theme inline {
--z-overlay: 200;
}So the value was not in question, and I went hunting for a stacking context instead — a transform, a backdrop-filter, a will-change on some ancestor creating a new context and trapping the drawer's z-index inside it. That is the usual answer to "my z-index is correct and ignored", and I burned real time on it. backdrop-blur-sm sitting right there on the scrim made it look plausible.
Wrong question. I had checked the computed style of the drawer once, seen z-index: auto, and read that as "the stacking context is swallowing it" rather than the simpler explanation: there was no rule to apply. I never checked whether z-overlay was a class at all.
The actual mechanism: theme namespaces
Tailwind v4 moved configuration out of tailwind.config.js and into CSS (@config survives as a compat escape hatch), and the bridge between a CSS variable and a utility is the theme namespace. The prefix of the variable name decides which utility family it feeds:
--color-*→bg-*,text-*,border-*,fill-*, …--spacing-*→p-*,m-*,gap-*,w-*, …--radius-*→rounded-*--tracking-*→tracking-*--z-index-*→z-*
You can read this straight out of the installed package. In node_modules/tailwindcss/dist/lib.mjs (v4.3.2), the z utility is registered like this — the file is minified, so the line breaks are mine:
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"]. That array is the complete list of variable prefixes z-* will look at. --z-overlay is not in it, so z-overlay is not a candidate — there is no utility to generate.
Pull the same field for every utility and you get most of the namespace inventory:
grep -o 'themeKeys:\[[^]]*\]' node_modules/tailwindcss/dist/lib.mjs \
| grep -o -- '--[a-z-]*' | sort -u61 namespaces on 4.3.2. Two caveats before you treat that as canon: the narrower pattern themeKeys:\["--[a-z-]*"\] only catches utilities that read a single key and returns 43, and a few namespaces — --spacing most notably — are resolved through separate code paths and never show up in a themeKeys array at all. So it is a sniff test, not the spec. The part that matters holds either way: the list is finite, it is decided at build time, and --z-overlay is not on it. A variable whose prefix is not a namespace is, to the utility engine, just a variable.
You can watch it happen without a browser, using the compiler API directly:
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']))Output:
/*! 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);
}Both variables are emitted. Only one class is. I used @theme static there deliberately, because that mode is the most misleading of the three: the variable lands in :root, so you can inspect it in DevTools, confirm --z-overlay: 200, and conclude the token is healthy while the utility does not exist. Plain @theme prunes the unused variable, and @theme inline — which is what my globals.css uses — emits neither the variable nor the class. Every mode is silent; they just differ in how much false reassurance they hand you.
The failure mode is worse than "no z-index". A positioned element with z-index: auto is not promoted anywhere — it paints in tree order alongside the other positioned-but-unstacked elements. An element with any positive z-index paints in a later pass, unconditionally above all of them. So z-40 beats a broken z-overlay no matter where either sits in the DOM — and on routes where nothing else declares a positive z-index, the same broken class looks like it works, because tree order happens to be flattering. That is why it read as a route-specific rendering quirk rather than a missing class.
The fix
A rename of the variable, and nothing else. 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 */
}The utility name stays z-overlay, because Tailwind strips the namespace prefix off the token name. All 22 call sites across 17 components — Dialog, Select, Tooltip, DropdownMenu, CommandPalette, Lightbox, LandingHeader — were already correct and needed no edit.
How to actually catch it
Grep the source and you learn nothing: z-overlay is present in 17 files whether or not it resolves. Grep the build output:
pnpm build
grep -o '\.z-overlay{[^}]*}' .next/static/chunks/*.cssWorking:
.next/static/chunks/2qvhgub5q_n5s.css:.z-overlay{z-index:200}Broken: exit code 1, no output. That single command is the difference between "the token is defined" and "the class exists". Run it on any custom-named utility you introduce — it generalizes to bg-*, rounded-*, anything you tokenized by hand.
One last thing, which is why I only ever hit this in one project. My other four templates define no --z-index-* token at all: three of them stack with arbitrary values — z-[90], z-[100], z-[250] — and the fourth writes z-index by hand in plain CSS. Both routes bypass the theme entirely and always compile. Booking is the only one where I promoted the magic numbers to a named token, which is the only way to trip the namespace rule. It also means copying a component out of booking into any of the other four would silently drop its z-index: the class name travels, the token does not.
This is the pattern I ship in the booking template, live demo at booking.violettadev.com.
Verified on tailwindcss@4.3.2 with next@16.2.10.