En Tailwind v4, --z-overlay: 200 dentro de @theme no crea una utilidad z-overlay. Las utilidades leen desde namespaces, y el de z- es --z-index-. No hay error ni warning. La clase simplemente no existe, el elemento cae de vuelta a z-index: auto, y cualquier cosa con un z-index positivo real se pinta encima — en algunas rutas sí, en otras no. La comprobación que de verdad lo zanja es hacer grep del nombre de la clase en el CSS compilado, no en el código fuente.
El síntoma
Nav móvil de admin en mi template de booking. Tocas la hamburguesa, el drawer entra deslizándose — y el backdrop que oscurece la pantalla queda encima. El drawer se ve a través del scrim, teñido y borroso, y cada toque dentro de él cierra el menú en lugar de navegar.
En desktop iba bien. Solo por debajo del breakpoint md. El build pasaba, el lint pasaba, los tests pasaban.
Ambos elementos viven en el mismo archivo, src/components/admin/AdminSidebar.tsx, a un par de docenas de líneas de distancia:
// 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 es mayor que 40. El drawer debería ganar.
Lo que asumí
Asumí que z-overlay resolvía a 200, porque el token lo había escrito yo mismo en src/app/globals.css:
@theme inline {
--z-overlay: 200;
}Así que el valor no estaba en duda, y me fui a cazar un stacking context — un transform, un backdrop-filter, un will-change en algún ancestro creando un contexto nuevo y atrapando ahí dentro el z-index del drawer. Esa es la respuesta habitual a "mi z-index es correcto y lo ignoran", y quemé tiempo real en ella. El backdrop-blur-sm ahí mismo en el scrim la hacía parecer plausible.
Pregunta equivocada. Había mirado el computed style del drawer una vez, visto z-index: auto, y lo leí como "el stacking context se lo está tragando" en lugar de la explicación más simple: no había ninguna regla que aplicar. Nunca comprobé si z-overlay era siquiera una clase.
El mecanismo real: los namespaces del tema
Tailwind v4 sacó la configuración de tailwind.config.js y la metió en CSS (@config sobrevive como escape hatch de compatibilidad), y el puente entre una variable CSS y una utilidad es el namespace del tema. El prefijo del nombre de la variable decide a qué familia de utilidades alimenta:
--color-*→bg-*,text-*,border-*,fill-*, …--spacing-*→p-*,m-*,gap-*,w-*, …--radius-*→rounded-*--tracking-*→tracking-*--z-index-*→z-*
Esto lo puedes leer directamente del paquete instalado. En node_modules/tailwindcss/dist/lib.mjs (v4.3.2), la utilidad z se registra así — el archivo está minificado, así que los saltos de línea son míos:
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"]. Ese array es la lista completa de prefijos de variable que z-* va a mirar. --z-overlay no está en ella, así que z-overlay no es candidato — no hay utilidad que generar.
Saca el mismo campo para cada utilidad y tienes casi todo el inventario de namespaces:
grep -o 'themeKeys:\[[^]]*\]' node_modules/tailwindcss/dist/lib.mjs \
| grep -o -- '--[a-z-]*' | sort -u61 namespaces en 4.3.2. Dos advertencias antes de que tomes eso como canon: el patrón más estrecho themeKeys:\["--[a-z-]*"\] solo captura las utilidades que leen una única clave y devuelve 43, y unos cuantos namespaces — --spacing el más notorio — se resuelven por rutas de código aparte y nunca aparecen en un array themeKeys. Así que es un sniff test, no la especificación. La parte que importa se sostiene igual: la lista es finita, se decide en build time, y --z-overlay no está en ella. Una variable cuyo prefijo no es un namespace es, para el motor de utilidades, solo una variable.
Puedes verlo pasar sin navegador, usando la API del compilador directamente:
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']))Salida:
/*! 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);
}Se emiten las dos variables. Solo una clase. Ahí usé @theme static a propósito, porque ese modo es el más engañoso de los tres: la variable aterriza en :root, así que puedes inspeccionarla en DevTools, confirmar --z-overlay: 200, y concluir que el token está sano mientras la utilidad no existe. El @theme a secas poda la variable sin usar, y @theme inline — que es lo que usa mi globals.css — no emite ni la variable ni la clase. Todos los modos son silenciosos; solo se diferencian en cuánta falsa tranquilidad te entregan.
El modo de fallo es peor que "sin z-index". Un elemento posicionado con z-index: auto no se promueve a ningún lado — se pinta en orden de árbol junto a los demás elementos posicionados pero sin apilar. Un elemento con cualquier z-index positivo se pinta en una pasada posterior, incondicionalmente por encima de todos ellos. Así que z-40 le gana a un z-overlay roto sin importar dónde esté cada uno en el DOM — y en rutas donde nada más declara un z-index positivo, esa misma clase rota parece funcionar, porque el orden de árbol resulta favorable. Por eso se leía como una rareza de renderizado propia de una ruta y no como una clase que falta.
El arreglo
Un rename de la variable, y nada más. 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 */
}El nombre de la utilidad sigue siendo z-overlay, porque Tailwind le quita el prefijo del namespace al nombre del token. Los 22 call sites repartidos en 17 componentes — Dialog, Select, Tooltip, DropdownMenu, CommandPalette, Lightbox, LandingHeader — ya estaban correctos y no necesitaron ninguna edición.
Cómo cazarlo de verdad
Haces grep del código fuente y no aprendes nada: z-overlay está presente en 17 archivos, resuelva o no. Haz grep del output del build:
pnpm build
grep -o '\.z-overlay{[^}]*}' .next/static/chunks/*.cssFuncionando:
.next/static/chunks/2qvhgub5q_n5s.css:.z-overlay{z-index:200}Roto: exit code 1, sin salida. Ese único comando es la diferencia entre "el token está definido" y "la clase existe". Córrelo sobre cualquier utilidad con nombre propio que introduzcas — generaliza a bg-*, rounded-*, a cualquier cosa que hayas tokenizado a mano.
Una última cosa, que es la razón por la que solo me topé con esto en un proyecto. Mis otros cuatro templates no definen ningún token --z-index-*: tres de ellos apilan con valores arbitrarios — z-[90], z-[100], z-[250] — y el cuarto escribe z-index a mano en CSS plano. Ambas rutas se saltan el tema por completo y siempre compilan. Booking es el único donde promoví los números mágicos a un token con nombre, que es la única forma de tropezar con la regla del namespace. También significa que copiar un componente de booking a cualquiera de los otros cuatro dejaría caer su z-index en silencio: el nombre de la clase viaja, el token no.
Este es el patrón que uso en el template de booking, demo en vivo en booking.violettadev.com.
Verificado en tailwindcss@4.3.2 con next@16.2.10.