In Tailwind v4, --z-overlay: 200 dentro @theme non crea nessuna utility z-overlay. Le utility leggono dai namespace, e quello di z- è --z-index-. Non fallisce niente e non avvisa niente. La classe semplicemente non esiste, l'elemento ricade su z-index: auto, e qualsiasi cosa con uno z-index positivo vero ci viene dipinta sopra — su certe route sì, su altre no. Il controllo che chiude davvero la questione è greppare il nome della classe nel CSS compilato, non nei sorgenti.
Il sintomo
Nav admin mobile nel mio template di prenotazioni. Tocco l'hamburger, il drawer entra scorrendo — e il backdrop che scurisce si piazza sopra. Il drawer si vede attraverso lo scrim, tinto e sfocato, e ogni tap al suo interno chiude il menu invece di navigare.
Su desktop tutto a posto. Solo sotto il breakpoint md. Build passata, lint passato, test passati.
Entrambi gli elementi stanno nello stesso file, src/components/admin/AdminSidebar.tsx, a un paio di decine di righe di distanza:
// 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 è maggiore di 40. Dovrebbe vincere il drawer.
Cosa davo per scontato
Davo per scontato che z-overlay si risolvesse in 200, perché il token l'avevo scritto io stesso in src/app/globals.css:
@theme inline {
--z-overlay: 200;
}Quindi il valore non era in discussione, e sono andato a caccia di uno stacking context — un transform, un backdrop-filter, un will-change su qualche antenato che creasse un nuovo contesto e ci intrappolasse dentro lo z-index del drawer. È la risposta classica a «il mio z-index è corretto e viene ignorato», e ci ho bruciato tempo vero. Il backdrop-blur-sm lì sopra sullo scrim la faceva sembrare plausibile.
Domanda sbagliata. Avevo controllato una volta lo stile calcolato del drawer, visto z-index: auto, e l'ho letto come «lo stacking context se lo sta mangiando» invece che come la spiegazione più semplice: non c'era nessuna regola da applicare. Non ho mai verificato se z-overlay fosse una classe, punto.
Il meccanismo vero: i namespace del tema
Tailwind v4 ha spostato la configurazione da tailwind.config.js al CSS (@config sopravvive come via di fuga per compatibilità), e il ponte tra una variabile CSS e una utility è il namespace del tema. Il prefisso del nome della variabile decide quale famiglia di utility alimenta:
--color-*→bg-*,text-*,border-*,fill-*, …--spacing-*→p-*,m-*,gap-*,w-*, …--radius-*→rounded-*--tracking-*→tracking-*--z-index-*→z-*
Lo si legge direttamente dal pacchetto installato. In node_modules/tailwindcss/dist/lib.mjs (v4.3.2), la utility z è registrata così — il file è minificato, quindi gli a capo sono miei:
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"]. Quell'array è l'elenco completo dei prefissi di variabile che z-* andrà a guardare. --z-overlay non c'è, quindi z-overlay non è nemmeno un candidato — non c'è nessuna utility da generare.
Estrai lo stesso campo per ogni utility e ottieni buona parte dell'inventario dei namespace:
grep -o 'themeKeys:\[[^]]*\]' node_modules/tailwindcss/dist/lib.mjs \
| grep -o -- '--[a-z-]*' | sort -u61 namespace sulla 4.3.2. Due avvertenze prima di prenderlo per oro colato: il pattern più stretto themeKeys:\["--[a-z-]*"\] cattura solo le utility che leggono una singola chiave e ne restituisce 43, e alcuni namespace — --spacing su tutti — vengono risolti da percorsi di codice separati e non compaiono mai in un array themeKeys. Quindi è un test al volo, non la specifica. La parte che conta regge comunque: la lista è finita, si decide a build time, e --z-overlay non ci sta dentro. Una variabile il cui prefisso non è un namespace, per il motore delle utility, è solo una variabile.
Lo puoi vedere succedere senza browser, usando direttamente l'API del compilatore:
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);
}Vengono emesse entrambe le variabili. Una sola classe. Lì ho usato @theme static di proposito, perché è il più ingannevole dei tre modi: la variabile finisce in :root, quindi puoi ispezionarla nei DevTools, confermare --z-overlay: 200, e concludere che il token sta benissimo mentre la utility non esiste. Il @theme normale pota la variabile inutilizzata, e @theme inline — che è quello che usa il mio globals.css — non emette né la variabile né la classe. Tutti i modi sono silenziosi; cambia solo quanta falsa sicurezza ti danno.
Il modo in cui fallisce è peggio di «nessuno z-index». Un elemento posizionato con z-index: auto non viene promosso da nessuna parte — viene dipinto nell'ordine dell'albero, insieme agli altri elementi posizionati ma non impilati. Un elemento con un qualsiasi z-index positivo viene dipinto in una passata successiva, incondizionatamente sopra tutti quelli. Quindi z-40 batte un z-overlay rotto a prescindere da dove stiano i due nel DOM — e sulle route dove nient'altro dichiara uno z-index positivo, la stessa classe rotta sembra funzionare, perché l'ordine dell'albero è per caso favorevole. Ecco perché sembrava una stranezza di rendering legata alla route e non una classe mancante.
Il fix
Un rename della variabile, e basta. 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 */
}Il nome della utility resta z-overlay, perché Tailwind toglie il prefisso di namespace dal nome del token. Tutti i 22 punti di utilizzo sparsi su 17 componenti — Dialog, Select, Tooltip, DropdownMenu, CommandPalette, Lightbox, LandingHeader — erano già corretti e non hanno richiesto nessuna modifica.
Come beccarlo davvero
Greppare i sorgenti non ti dice niente: z-overlay c'è in 17 file, che si risolva o no. Greppa l'output di build:
pnpm build
grep -o '\.z-overlay{[^}]*}' .next/static/chunks/*.cssFunzionante:
.next/static/chunks/2qvhgub5q_n5s.css:.z-overlay{z-index:200}Rotto: exit code 1, nessun output. Quel singolo comando è la differenza tra «il token è definito» e «la classe esiste». Lancialo su qualsiasi utility con nome custom che introduci — si generalizza a bg-*, rounded-*, qualsiasi cosa tu abbia tokenizzato a mano.
Ultima cosa, ed è il motivo per cui l'ho beccato solo in un progetto. Gli altri quattro template non definiscono nessun token --z-index-*: tre impilano con valori arbitrari — z-[90], z-[100], z-[250] — e il quarto scrive z-index a mano in CSS puro. Entrambe le strade bypassano completamente il tema e compilano sempre. Booking è l'unico dove ho promosso i numeri magici a token con nome, che è l'unico modo di inciampare nella regola dei namespace. Vuol dire anche che copiare un componente da booking in uno degli altri quattro ne farebbe cadere lo z-index in silenzio: il nome della classe viaggia, il token no.
Questo è il pattern che spedisco nel template booking, demo live su booking.violettadev.com.
Verificato su tailwindcss@4.3.2 con next@16.2.10.