El árbol de contextos y la comparación por niveles
Cómo se organizan los contextos de apilamiento en una jerarquía paralela al DOM y por qué comparar dos elementos exige subir hasta su contexto común.
Los contextos de apilamiento forman un árbol. No es el árbol del DOM: es un subconjunto suyo, porque solo algunos elementos crean contexto y el resto queda absorbido por el contexto de su antepasado más cercano que sí lo cree. Todo el comportamiento desconcertante de z-index se deduce de una sola regla sobre ese árbol, y una vez la tienes, los bugs dejan de ser sorpresas y pasan a ser consultas.
- Construir el árbol de contextos de un fragmento de HTML dado.
- Aplicar la regla de comparación por niveles a dos elementos cualesquiera.
- Explicar por qué un
z-indexalto puede perder contra uno bajo. - Localizar el contexto común de dos elementos que se solapan mal.
Del árbol del DOM al árbol de contextos
Cada elemento pertenece a exactamente un contexto de apilamiento: el que crea su antepasado más cercano que cumpla alguna de las condiciones de la lista. Si ningún antepasado la cumple, pertenece al contexto raíz, el que crea html.
Los elementos que no crean contexto no aparecen en este árbol. Se dice que quedan asimilados por el contexto de su padre. Un div sin ninguna propiedad especial no es un nodo del árbol de contextos; su z-index, si lo tuviera y no estuviera posicionado, sería ignorado por completo.
Considera este HTML.
<body>
<header class="barra">
<nav class="menu">Menú</nav>
</header>
<main class="contenido">
<article class="tarjeta">
<div class="etiqueta">Nuevo</div>
</article>
</main>
<div class="modal">Modal</div>
</body>
.barra { position: sticky; inset-block-start: 0; z-index: 10; }
.menu { position: absolute; z-index: 9999; }
.contenido{ opacity: .98; }
.tarjeta { position: relative; z-index: 1; }
.etiqueta { position: absolute; z-index: 500; }
.modal { position: fixed; z-index: 20; }
El árbol de contextos resultante:
flowchart TB R[Contexto raiz html] --> A[barra sticky z-index 10] R --> B[contenido opacity 0.98 z-index auto] R --> C[modal fixed z-index 20] A --> A1[menu absolute z-index 9999] B --> B1[tarjeta relative z-index 1] B1 --> B2[etiqueta absolute z-index 500] style R fill:#89b4fa,color:#11111b style A fill:#cba6f7,color:#11111b style B fill:#cba6f7,color:#11111b style C fill:#a6e3a1,color:#11111b style A1 fill:#f9e2af,color:#11111b style B1 fill:#f9e2af,color:#11111b style B2 fill:#f38ba8,color:#11111b
Fíjate en .contenido: crea contexto por su opacity de 0,98, pero su z-index es auto porque no está posicionado. En el contexto raíz compite en el paso 6, el de los auto y los ceros. Y .header y .modal tienen z-index de 10 y 20, que están en el paso 7. Ambos quedan por encima de .contenido y de todo su subárbol, sin excepción.
La regla de comparación
Para saber cuál de dos elementos se pinta encima:
- Encuentra el contexto de apilamiento común más cercano de los dos.
- Sube desde cada elemento hasta llegar a un hijo directo de ese contexto común. Esos dos ancestros son los que compiten.
- Compara los
z-indexde esos dos ancestros, no los de los elementos originales. - Si empatan, gana el que esté después en el orden de documento.
Aplícala a .etiqueta con z-index: 500 y .barra con z-index: 10. El contexto común es el raíz. Subiendo desde .etiqueta llegamos a .contenido, que es hijo directo del raíz con z-index: auto. Subiendo desde .barra llegamos a .barra misma, con z-index: 10. Comparamos auto contra 10: gana .barra. La etiqueta con 500 queda debajo de una barra con 10, y no hay número que puedas escribir en .etiqueta que lo cambie.
La analogía que mejor funciona es la de los números de versión. Los z-index de la cadena de ancestros forman una tupla, y la comparación es lexicográfica:
.etiquetatiene la tuplaauto, 1, 500— o si prefieres,0.1.500..barratiene la tupla10, es decir10.0.0..menutiene la tupla10, 9999, es decir10.9999..modaltiene20.
Ordenadas: 0.1.500 menor que 10.0.0 menor que 10.9999 menor que 20. El modal está encima de todo, el menú encima de la barra, y la etiqueta debajo de todo aunque tenga el número más aparatoso. La comparación se decide en el primer componente que difiere, exactamente igual que con las versiones semánticas.
Cómo se lee esto en la práctica
El procedimiento manual, cuando algo se solapa mal, tiene tres pasos y no falla.
Primero, identifica los dos elementos que compiten de verdad. No son los que ves solaparse: son sus ancestros a nivel del contexto común. En el ejemplo, no compiten la etiqueta y la barra, compiten .contenido y .barra.
Segundo, comprueba si el techo es evitable. Si .contenido crea contexto por un opacity: .98 que estaba ahí por un efecto de aparición que ya no se usa, quitarlo resuelve el problema de raíz y sin números mágicos.
Tercero, si el techo no es evitable, sube el número del ancestro, no el del elemento. Es la conclusión operativa de toda la lección: el z-index que hay que tocar casi nunca es el del elemento que no se ve.
Chrome DevTools tiene una vista dedicada para esto en el panel Layers, que muestra la composición real, y una insignia junto a los nodos del panel Elements que marca los que crean contexto. Firefox marca los nodos creadores en el inspector. Cuando el árbol es grande, esas herramientas ahorran el recorrido manual, pero conviene saber hacerlo a mano porque a menudo el problema está en un ancestro que no habrías mirado.
La lectura pesimista de todo esto es que los contextos de apilamiento son una trampa. La lectura útil es la contraria: son el único mecanismo que impide que el z-index de una página sea un espacio de nombres global. Piensa qué pasaría sin ellos: cualquier componente de cualquier librería que escribiera z-index: 100 competiría directamente con tu modal, tu barra y tu tooltip, y la única forma de coexistir sería que todo el ecosistema se pusiera de acuerdo en un registro central de números. Es exactamente el problema que tuvo CSS antes de @layer con la especificidad, y antes de los módulos con los nombres de clase. El contexto de apilamiento es a z-index lo que el ámbito léxico es a las variables: un mecanismo de encapsulación que convierte un número global en un número local. Cuando lo asumes, dejas de escribir z-index: 9999 y empiezas a hacer lo que el mecanismo pide: definir contextos deliberadamente en las fronteras de tus componentes, con isolation: isolate, y usar dentro de cada uno una escala pequeña de tres o cuatro valores que solo tiene sentido ahí.
- Reproduce el HTML y el CSS del ejemplo y verifica que la etiqueta con 500 queda debajo de la barra con 10.
- Quita el
opacityde.contenidoy comprueba que la etiqueta pasa por encima sin tocar suz-index. - Escribe la tupla de
z-indexde cada uno de los cinco elementos y ordénalas a mano; compara con lo que ves. - Activa la insignia de contexto de apilamiento en DevTools y localiza los tres nodos creadores.
- Añade un
filter: saturate(1.1)a.tarjetay explica sin ejecutar qué cambia en el árbol.