wandres.dev
POSICIONAMIENTO · De static a fixed, y el bloque contenedor

Cuando un ancestro con transform convierte tu fixed en absolute

El caso clásico: qué propiedades roban el viewport a un descendiente fijo, por qué la especificación lo decidió así, y las cuatro formas de arreglarlo.

⏱ 17 min

Tienes un modal con position: fixed; inset: 0 que debería cubrir la pantalla. Cubre solo un trozo, se desplaza con el scroll y aparece cortado. No has tocado el modal: alguien añadió una animación de entrada con transform a un contenedor tres niveles más arriba. Este es probablemente el bug de posicionamiento más caro de diagnosticar de todo el CSS, porque la causa está lejos del síntoma y la propiedad culpable no tiene ninguna relación aparente con el problema.

🎯 Al terminar esta lección sabrás
  • Enumerar las propiedades que convierten a un ancestro en bloque contenedor de descendientes fijos.
  • Reconocer el síntoma y localizar el ancestro culpable en menos de un minuto.
  • Aplicar la solución adecuada de las cuatro disponibles.
  • Explicar por qué la especificación no podía decidir otra cosa.

El caso

<div class="pagina">
  <section class="panel">
    <div class="modal">Debería cubrir la pantalla</div>
  </section>
</div>
.panel { transform: translateY(0); }        /* puesto para una animación de entrada */
.modal { position: fixed; inset: 0; background: oklch(0% 0 0 / .6); }

El .modal no cubre la pantalla: cubre exactamente .panel. Y al hacer scroll, se desplaza con la página en lugar de quedarse quieto.

Lo que ha ocurrido: .panel tiene un transform con valor distinto de none, y eso lo convierte en el bloque contenedor de todos sus descendientes con position: fixed y position: absolute. El fixed deja de referirse al viewport y pasa a referirse a la caja de relleno de .panel. Es decir, se comporta como un absolute respecto a .panel, aunque su position computado siga diciendo fixed.

Lo especialmente cruel del caso: transform: translateY(0) no mueve nada. Es la identidad. Y aun así basta para romperlo, porque la regla habla del valor de la propiedad, no de su efecto. Lo mismo pasa con transform: none frente a transform: scale(1): el primero no rompe nada, el segundo sí.

La lista completa de propiedades culpables

Un ancestro se convierte en bloque contenedor de los descendientes absolute y fixed cuando tiene alguna de estas propiedades con valor distinto del inicial:

  • transform, translate, rotate, scale o perspective con valor distinto de none.
  • filter con valor distinto de none.
  • backdrop-filter con valor distinto de none.
  • contain con valor layout, paint, strict o content, y por extensión content-visibility: auto.
  • will-change nombrando cualquiera de las anteriores.

Y hay tres que no están y que mucha gente añade a la lista por analogía. opacity menor que 1 y isolation: isolate crean contexto de apilamiento pero no roban el bloque contenedor: un fixed dentro de un opacity: .9 sigue anclado al viewport, aunque su apilamiento quede encerrado. Y container-type dejó de robarlo: hasta Chrome 129, Firefox 133 y Safari 18.4 aplicaba containment de layout y sí lo hacía, pero el grupo de trabajo revirtió esa decisión y hoy solo aplica containment de estilo y tamaño. Si arrastras un diagnóstico de hace dos años, compruébalo antes de darlo por bueno. Distinguir estas listas es lo que te evita perseguir la propiedad equivocada.

Existen además diferencias históricas conocidas entre motores en el tratamiento de perspective y filter como creadores de bloque contenedor. Si tu caso depende exactamente de una de esas dos, compruébalo en los tres motores antes de darlo por bueno.

flowchart TB
A[Un position fixed no cubre el viewport] --> B[Sube por los ancestros]
B --> C{Alguno tiene transform translate rotate scale o perspective}
C -->|Si| D[Ahi esta el ladron del bloque contenedor]
C -->|No| E{Alguno tiene filter o backdrop-filter}
E -->|Si| D
E -->|No| F{Alguno tiene contain layout o paint o content-visibility auto}
F -->|Si| D
F -->|No| G{Alguno tiene will-change con esas propiedades}
G -->|Si| D
G -->|No| H[El problema no es el bloque contenedor, revisa overflow y apilamiento]
style A fill:#f38ba8,color:#11111b
style D fill:#f9e2af,color:#11111b
style H fill:#89b4fa,color:#11111b

El diagnóstico manual más rápido: pon un borde temporal al elemento fijo con inset: 0 y mira qué rectángulo dibuja. Ese rectángulo es tu bloque contenedor real, y el elemento que lo dibuja es el culpable. No hace falta inspeccionar propiedades una a una.

Las cuatro soluciones

1. Quitar la propiedad culpable. Es la mejor cuando la propiedad estaba de más. Un will-change: transform residual, un filter: blur(0) para forzar la GPU, un transform: translateZ(0) heredado de una época en que hacía falta. Todos sobran en 2026 y quitarlos arregla el problema de raíz.

2. Mover el elemento fijo fuera del subárbol. Si el modal se renderiza con JavaScript, un portal que lo monte como hijo de body lo saca del alcance de cualquier ancestro problemático. Es la solución que llevan implementando los frameworks desde hace una década, precisamente por este motivo.

3. Mover la propiedad culpable a otro elemento. Si .panel necesita la transformación para animarse, mete un envoltorio interior que la lleve y deja el modal fuera de él. Cuesta un nodo del DOM y resuelve el problema sin renunciar a nada.

<section class="panel">
  <div class="panel-animado">Contenido que se anima</div>
  <div class="modal">Ya no está dentro del transform</div>
</section>

4. Usar la capa superior. La solución correcta en 2026 para cualquier cosa que deba flotar sobre toda la página. Un elemento en la capa superior no depende de bloques contenedores intermedios porque no se pinta en el árbol normal.

<dialog id="confirmacion">
  <p>¿Seguro que quieres continuar?</p>
  <form method="dialog"><button>Cancelar</button></form>
</dialog>
document.querySelector('#confirmacion').showModal();

Ese dialog cubre la pantalla aunque esté anidado dentro de diez contenedores transformados, y su ::backdrop cubre el viewport entero. Es inmune a todo lo que cuenta esta lección.

Por qué la especificación tenía que decidir esto

No es una decisión arbitraria y entenderla evita tratarla como un bug. Un elemento transformado establece un nuevo sistema de coordenadas para su subárbol: todo lo que hay dentro se dibuja en el espacio transformado. Un descendiente fixed pide, por definición, colocarse respecto al viewport.

Las dos cosas son incompatibles. Si el motor respetara el viewport para el descendiente, tendría que sacarlo del sistema de coordenadas de su ancestro, es decir, dejar de aplicarle la transformación. El elemento estaría visualmente fuera del subárbol que lo contiene, pero heredaría sus estilos y participaría de su apilamiento. Peor aún: si el ancestro está animado, el descendiente debería quedarse quieto mientras todo lo que lo rodea se mueve, con lo que el recorte, las máscaras y los filtros del ancestro dejarían de tener sentido sobre él.

Ante esa incompatibilidad la especificación eligió la coherencia geométrica: dentro de un subárbol transformado, todo está en el sistema de coordenadas transformado, incluidos los fijos. Cualquier otra decisión habría producido situaciones sin respuesta bien definida.

Este bug es la razón por la que existe la capa superior

La historia merece contarse porque cambia cómo ves el problema. Durante quince años, la única forma de poner algo por encima de toda la página fue position: fixed con un z-index grande, y ese patrón es frágil por dos motivos independientes que se combinan: los contextos de apilamiento pueden encerrarlo y los bloques contenedores pueden desplazarlo. Un modal que funcionaba perfectamente se rompía porque alguien, en otro equipo, añadía un transform de hover a una tarjeta que resultaba estar en la cadena de ancestros. La respuesta de la industria fue el portal: montar el nodo como hijo de body para escapar del subárbol. Funciona, pero tiene su propio coste —el elemento pierde la herencia de estilos de donde conceptualmente vive, y hay que gestionar a mano el foco, el inert del fondo, la tecla de escape y el orden de lectura para lectores de pantalla—. La capa superior es la respuesta de la plataforma a ese problema entero: un contenedor de renderizado que existe fuera del árbol de pintado, donde el elemento sigue estando en su sitio del DOM y por tanto hereda lo que le corresponde, pero se pinta por encima de todo sin depender de ningún ancestro. dialog y popover no son azúcar sintáctico sobre lo que ya hacías: son la eliminación de una clase entera de bugs, esta.

⚔️ Rompe y arregla el fixed
  1. Reproduce el caso con transform: translateY(0) en un ancestro y confirma que el modal se recorta.
  2. Sustituye la transformación por filter: blur(0) y comprueba que el bug es idéntico.
  3. Prueba con opacity: .9 y verifica que no rompe el fixed, solo su apilamiento.
  4. Pon un borde al modal con inset: 0 y usa el rectángulo dibujado para identificar al ancestro culpable sin inspeccionar propiedades.
  5. Convierte el modal en un dialog abierto con showModal() y comprueba que cubre la pantalla pese al transform del ancestro.