No Tailwind v4, --z-overlay: 200 dentro de @theme não cria um utilitário z-overlay. Os utilitários leem a partir de namespaces, e o de z- é --z-index-. Não há erro nem warning. A classe simplesmente não existe, o elemento cai para z-index: auto, e qualquer coisa com um z-index positivo a sério pinta por cima — nalgumas rotas sim, noutras não. A verificação que resolve mesmo a questão é fazer grep do nome da classe no CSS compilado, e não no código-fonte.
O sintoma
Nav mobile do admin no meu template de booking. Carregas no hambúrguer, o drawer entra a deslizar — e o backdrop que escurece o ecrã fica por cima. O drawer vê-se através do scrim, tingido e desfocado, e cada toque lá dentro fecha o menu em vez de navegar.
No desktop estava tudo bem. Só abaixo do breakpoint md. O build passava, o lint passava, os tests passavam.
Os dois elementos vivem no mesmo ficheiro, src/components/admin/AdminSidebar.tsx, a umas duas dúzias de linhas de distância:
// 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 é maior que 40. O drawer devia ganhar.
O que assumi
Assumi que z-overlay resolvia para 200, porque o token tinha sido escrito por mim em src/app/globals.css:
@theme inline {
--z-overlay: 200;
}Ou seja, o valor não estava em causa, e fui à caça de um stacking context — um transform, um backdrop-filter, um will-change nalgum ancestral a criar um contexto novo e a prender lá dentro o z-index do drawer. É a resposta habitual para "o meu z-index está correto e é ignorado", e queimei tempo a sério nisso. O backdrop-blur-sm ali mesmo no scrim tornava-a plausível.
Pergunta errada. Tinha visto o computed style do drawer uma vez, visto z-index: auto, e li aquilo como "o stacking context está a engoli-lo" em vez da explicação mais simples: não havia regra nenhuma para aplicar. Nunca verifiquei se z-overlay era sequer uma classe.
O mecanismo real: os namespaces do tema
O Tailwind v4 tirou a configuração do tailwind.config.js e passou-a para CSS (@config sobrevive como escape hatch de compatibilidade), e a ponte entre uma variável CSS e um utilitário é o namespace do tema. O prefixo do nome da variável decide que família de utilitários é que alimenta:
--color-*→bg-*,text-*,border-*,fill-*, …--spacing-*→p-*,m-*,gap-*,w-*, …--radius-*→rounded-*--tracking-*→tracking-*--z-index-*→z-*
Isto podes lê-lo diretamente do pacote instalado. Em node_modules/tailwindcss/dist/lib.mjs (v4.3.2), o utilitário z é registado assim — o ficheiro está minificado, por isso as quebras de linha são minhas:
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"]. Esse array é a lista completa de prefixos de variável que z-* vai olhar. --z-overlay não está lá, por isso z-overlay não é candidato — não há utilitário para gerar.
Extrai o mesmo campo para cada utilitário e ficas com quase todo o inventário de namespaces:
grep -o 'themeKeys:\[[^]]*\]' node_modules/tailwindcss/dist/lib.mjs \
| grep -o -- '--[a-z-]*' | sort -u61 namespaces na 4.3.2. Duas ressalvas antes de tomares isso como dogma: o padrão mais restrito themeKeys:\["--[a-z-]*"\] só apanha os utilitários que leem uma única chave e devolve 43, e uns quantos namespaces — --spacing sobretudo — são resolvidos por caminhos de código separados e nunca aparecem num array themeKeys. Portanto é um sniff test, não a especificação. A parte que interessa mantém-se de qualquer forma: a lista é finita, é decidida em build time, e --z-overlay não está nela. Uma variável cujo prefixo não é um namespace é, para o motor de utilitários, apenas uma variável.
Podes ver isto a acontecer sem browser, usando a API do compilador diretamente:
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);
}As duas variáveis são emitidas. Só uma classe é. Usei ali @theme static de propósito, porque esse modo é o mais enganador dos três: a variável aterra em :root, portanto podes inspecioná-la nas DevTools, confirmar --z-overlay: 200, e concluir que o token está saudável enquanto o utilitário não existe. O @theme simples poda a variável não usada, e @theme inline — que é o que o meu globals.css usa — não emite nem a variável nem a classe. Todos os modos são silenciosos; só diferem na quantidade de falso descanso que te dão.
O modo de falha é pior do que "sem z-index". Um elemento posicionado com z-index: auto não é promovido para lado nenhum — pinta por ordem de árvore ao lado dos outros elementos posicionados mas não empilhados. Um elemento com qualquer z-index positivo pinta numa passagem posterior, incondicionalmente acima de todos eles. Por isso z-40 ganha a um z-overlay partido, esteja cada um onde estiver no DOM — e em rotas onde mais nada declara um z-index positivo, essa mesma classe partida parece funcionar, porque a ordem de árvore calha ser favorável. Foi por isso que aquilo se leu como uma esquisitice de renderização específica da rota e não como uma classe em falta.
A correção
Um rename da variável, e mais nada. 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 */
}O nome do utilitário continua a ser z-overlay, porque o Tailwind retira o prefixo do namespace ao nome do token. Os 22 call sites espalhados por 17 componentes — Dialog, Select, Tooltip, DropdownMenu, CommandPalette, Lightbox, LandingHeader — já estavam corretos e não precisaram de qualquer edição.
Como apanhar isto a sério
Fazes grep ao código-fonte e não aprendes nada: z-overlay está presente em 17 ficheiros, resolva ou não. Faz grep ao output do build:
pnpm build
grep -o '\.z-overlay{[^}]*}' .next/static/chunks/*.cssA funcionar:
.next/static/chunks/2qvhgub5q_n5s.css:.z-overlay{z-index:200}Partido: exit code 1, sem output. Esse único comando é a diferença entre "o token está definido" e "a classe existe". Corre-o em qualquer utilitário com nome próprio que introduzas — generaliza para bg-*, rounded-*, para tudo o que tokenizaste à mão.
Uma última coisa, que é a razão pela qual só apanhei isto num projeto. Os meus outros quatro templates não definem token --z-index-* nenhum: três deles empilham com valores arbitrários — z-[90], z-[100], z-[250] — e o quarto escreve z-index à mão em CSS simples. Ambos os caminhos contornam o tema por completo e compilam sempre. O booking é o único onde promovi os números mágicos a um token com nome, que é a única maneira de tropeçar na regra do namespace. Também quer dizer que copiar um componente do booking para qualquer um dos outros quatro deixaria cair o z-index em silêncio: o nome da classe viaja, o token não.
Este é o padrão que uso no template de booking, demo em direto em booking.violettadev.com.
Verificado com tailwindcss@4.3.2 e next@16.2.10.