La técnica de rejilla: de 0fr a 1fr
Cómo se anima una altura desconocida sin medirla, por qué hace falta el envoltorio y el min-height, y los cuatro detalles que la rompen.
La técnica portable para animar una altura desconocida no anima la altura: anima el reparto de espacio de una pista de rejilla. Una pista de 0fr no recibe nada del espacio libre y una de 1fr lo recibe todo, y como las unidades fraccionarias son números, interpolan sin ningún problema. Es una solución oblicua, con dos requisitos que parecen arbitrarios hasta que sabes de dónde vienen, y funciona en los cuatro motores desde hace años.
- Implementar la técnica completa con su estructura mínima.
- Explicar por qué es necesario el envoltorio interior y qué pasa sin él.
- Justificar
min-height: 0a partir de las reglas de tamaño mínimo automático. - Reconocer los cuatro casos que la rompen y cómo se corrigen.
La implementación
<div class="desplegable">
<button class="cabecera" aria-expanded="false" aria-controls="c1">
Detalles del pedido
</button>
<div class="cuerpo" id="c1">
<div class="contenido">
<p>Contenido de altura desconocida, que puede cambiar.</p>
</div>
</div>
</div>
.cuerpo {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0, 0, 1);
}
.contenido {
min-height: 0;
overflow: hidden;
}
.desplegable[data-abierto] .cuerpo {
grid-template-rows: 1fr;
}
const desplegable = document.querySelector('.desplegable');
const cabecera = desplegable.querySelector('.cabecera');
cabecera.addEventListener('click', () => {
const abierto = desplegable.toggleAttribute('data-abierto');
cabecera.setAttribute('aria-expanded', String(abierto));
});
Diecisiete líneas de CSS y cuatro de JavaScript, sin ninguna medición y sin ningún número que dependa del contenido. Si el texto crece, la animación sigue siendo correcta sin tocar nada.
Por qué funciona
grid-template-rows: 0fr define una pista cuyo tamaño es cero veces la fracción del espacio disponible. 1fr define una pista que se lleva todo el espacio libre. Como fr es una unidad numérica, la interpolación entre 0fr y 1fr es aritmética directa: en el 50 por ciento del progreso la pista vale 0.5fr.
La clave está en cómo se resuelve una pista fr en un contenedor sin altura definida. Cuando no hay espacio libre que repartir porque el contenedor se dimensiona por su contenido, una pista de 1fr acaba tomando el tamaño de su contenido, y una de 0fr toma cero. Los valores intermedios reparten proporcionalmente. El resultado es que la pista recorre suavemente el camino de cero al tamaño intrínseco del contenido, que es exactamente lo que queríamos y nunca hemos tenido que nombrar.
Por qué hace falta el envoltorio
El elemento con display: grid es el contenedor; la pista contiene a su hijo. Sin un hijo envolvente, el contenido directo sería el elemento de rejilla, y hay dos problemas.
El primero es que no tendrías dónde poner overflow: hidden. La pista se encoge, pero el contenido no se recorta solo: se desborda. Con la pista a 0.3fr y el contenido midiendo su altura natural, verías el contenido entero saliéndose. El recorte tiene que estar en el elemento de rejilla.
El segundo es que si el contenido son varios elementos, cada uno sería una pista distinta y grid-template-rows: 0fr solo afectaría a la primera. El envoltorio garantiza que hay exactamente una pista.
Poner overflow: hidden en el contenedor de rejilla en lugar de en el hijo no funciona correctamente en todos los casos, porque el contenedor con overflow distinto de visible establece un contexto de formato nuevo y cambia cómo se resuelve el tamaño de la pista. La colocación correcta es en el hijo.
Por qué hace falta min-height: 0
Este es el requisito que más gente omite y el que produce el síntoma de “se anima pero no llega a cerrarse del todo”.
Los elementos de rejilla y los de flexbox tienen una regla de tamaño mínimo automático: por defecto, min-height vale auto para ellos, y auto en ese contexto significa “no te encojas por debajo del tamaño de tu contenido”. La regla existe para evitar que el contenido se recorte accidentalmente en layouts complejos, y es razonable en general.
Aquí es exactamente lo que no queremos: queremos que el elemento se encoja hasta cero. min-height: 0 desactiva esa protección.
Sin él, la pista se anima hasta el valor mínimo que impone el contenido, y el desplegable se queda abierto con su altura completa aunque el CSS diga 0fr. Y como la transición sí se ejecuta, el síntoma es un movimiento que empieza y no llega a ninguna parte, que es más desconcertante que un fallo limpio.
Hay una consecuencia de que esto sea un reparto de espacio y no una altura que no aparece en ningún tutorial y que puede echar por tierra la elección: el contenedor de rejilla tiene que estar en un contexto donde su altura la determine su contenido. Si el .cuerpo está dentro de un contenedor con altura fija, o es a su vez un elemento de un flex con align-items: stretch, o está en una pista de otra rejilla que ya reparte espacio, entonces hay espacio libre real que repartir, y 1fr deja de significar “el tamaño del contenido” para significar “todo el espacio disponible”. El desplegable se abre hasta ocupar el contenedor entero en lugar de hasta su contenido, y la animación es correcta pero el destino es otro. Y hay una variante peor y más difícil de diagnosticar: si el ancestro tiene una altura que depende a su vez del contenido, el cambio de la pista modifica la altura del ancestro en cada fotograma, y cada fotograma dispara un layout que se propaga hacia arriba, con el coste que estudiamos en el nivel 2. Es la misma factura que animar height a mano, solo que escondida. La comprobación que hay que hacer antes de adoptar la técnica es de una línea: pon grid-template-rows: 1fr de forma permanente y mira si el elemento mide lo que su contenido. Si mide más, estás en un contexto con espacio libre y la técnica no va a hacer lo que crees. La corrección habitual es aislar el desplegable en un contenedor en flujo normal, o usar grid-template-rows: 0fr junto con align-content: start en el ancestro para que deje de estirar.
Los cuatro casos que la rompen
Uno: falta min-height: 0. Ya visto. Síntoma: no se cierra del todo.
Dos: el contenido tiene márgenes verticales. Los márgenes del elemento dentro del envoltorio no se recortan con overflow: hidden de la forma que esperas, y el desplegable cerrado conserva la altura de los márgenes. La corrección es mover el espaciado a padding del envoltorio, o poner el margen en un elemento interior adicional.
/* Roto: el margen del parrafo deja un residuo al cerrar. */
.contenido p { margin-block: 1rem; }
/* Correcto: el espaciado es padding del elemento que se recorta. */
.contenido { padding-block: 1rem; }
.contenido p { margin-block: 0; }
Tres: el envoltorio tiene border o padding propios. Igual que los márgenes: la caja del elemento de rejilla no baja de la suma de su borde y su relleno, así que queda un residuo. El espaciado va dentro del elemento recortado, no en él.
Cuatro: el contexto reparte espacio. El caso del recuadro anterior.
Un quinto caso que no rompe la animación pero sí la accesibilidad: si el contenido está recortado con overflow: hidden y sigue en el árbol de accesibilidad, un lector de pantalla lo lee aunque no se vea, y el tabulador entra en él. La corrección es combinar la técnica con visibility o con display y allow-discrete, tal y como vimos en el nivel 6:
.contenido {
min-height: 0;
overflow: hidden;
visibility: hidden;
transition: visibility 300ms;
}
.desplegable[data-abierto] .contenido {
visibility: visible;
}
El coste
Conviene ser honesto con lo que cuesta esta técnica, porque a menudo se presenta como si fuera gratis por ser “solo CSS”.
Animar grid-template-rows es animar una propiedad de layout. Dispara layout en cada fotograma, con todo lo que arrastra: repintado y rasterización de la zona afectada. No es una animación de compositor y no sobrevive a un hilo principal bloqueado.
Eso no la descalifica: para un desplegable que se abre de vez en cuando, el coste es asumible y no hay alternativa portable más barata. Pero significa dos cosas prácticas. La primera es que no conviene tener veinte desplegables animándose a la vez, porque cada uno paga su layout. La segunda es que la duración debería ser corta: cuanto menos dure, menos fotogramas caros hay.
Y hay una optimización que ayuda mucho y cuesta una línea: acotar el layout con contención, para que el recálculo no se propague más allá del desplegable.
.cuerpo {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 260ms cubic-bezier(0.2, 0, 0, 1);
contain: layout paint;
}
Ten en cuenta que contain: layout cambia la forma en que el elemento interactúa con su contexto, así que hay que comprobar que el diseño sigue siendo correcto. En un desplegable en flujo normal suele serlo.
- Implementa la versión completa y comprueba que cierra hasta cero exactamente.
- Quita
min-height: 0y mide la altura del cuerpo cerrado. Explica de dónde sale ese número. - Pon un margen vertical al contenido y mide otra vez. Corrígelo con
padding. - Mete el desplegable dentro de un contenedor flex con
align-items: stretchy comprueba hasta dónde se abre. - Registra el panel de rendimiento durante la apertura y localiza el layout por fotograma. Añade
contain: layout painty compara.