Medir con JavaScript, y la decisión final
La implementación robusta con medición y WAAPI, cómo mantenerla correcta cuando el contenido cambia, y la tabla que decide entre las cuatro alternativas.
Cuando ninguna de las técnicas declarativas encaja, queda la que siempre funcionó: medir la altura real desde JavaScript y animar hasta ella. Tiene la ventaja de ser exacta y la de dar control total sobre la interrupción, y tiene tres trampas que hacen que la mayoría de las implementaciones que circulan sean incorrectas en casos que aparecen a diario. Esta lección monta la versión robusta y cierra el nivel con el criterio para elegir entre las cuatro alternativas.
- Implementar un desplegable medido que devuelva el elemento a
autoal terminar. - Evitar las tres trampas de la medición: el momento, los márgenes y la interrupción.
- Mantener la altura correcta cuando el contenido cambia estando abierto.
- Elegir entre las cuatro alternativas con un criterio explícito.
La implementación robusta
const DURACION = 280;
const CURVA = 'cubic-bezier(0.2, 0, 0, 1)';
/**
* Despliega o repliega un elemento de altura desconocida.
* Devuelve una promesa que se resuelve al terminar.
*/
async function alternar(el, abrir) {
// Cancela cualquier animacion de altura en curso y captura el punto actual.
const previas = el.getAnimations().filter((a) => a.id === 'altura');
const alturaActual = el.getBoundingClientRect().height;
previas.forEach((a) => a.cancel());
// Estado estable desde el que medir el destino.
el.style.overflow = 'hidden';
el.style.height = abrir ? 'auto' : '0px';
const alturaDestino = el.getBoundingClientRect().height;
// Duracion proporcional al recorrido pendiente, con suelo y techo.
const recorrido = Math.abs(alturaDestino - alturaActual);
const total = abrir ? Math.max(alturaDestino, 1) : Math.max(alturaActual, 1);
const dur = Math.min(Math.max(DURACION * (recorrido / total), 90), DURACION);
const anim = el.animate(
[{ height: `${alturaActual}px` }, { height: `${alturaDestino}px` }],
{ duration: dur, easing: CURVA, fill: 'backwards' }
);
anim.id = 'altura';
try {
await anim.finished;
// Al terminar, devolver el control al layout.
el.style.height = abrir ? 'auto' : '0px';
el.style.overflow = abrir ? '' : 'hidden';
} catch {
// Cancelada por otra llamada: no tocar nada, manda la nueva.
}
}
Cuatro decisiones de esa función merecen explicación.
Se mide el punto de partida antes de cancelar. Si la animación anterior sigue corriendo, la altura actual es un valor intermedio, y ese es el punto correcto desde el que continuar. Cancelar primero y medir después daría el valor de la hoja de estilos, produciendo un salto.
Se mide el destino poniendo el valor final y leyendo. Es la única forma de conocer la altura de auto: aplicarla y preguntar. La lectura fuerza layout, con el coste que ya conoces, pero ocurre una sola vez por apertura y no por fotograma.
La duración se acorta proporcionalmente al recorrido pendiente. Es la reversión acortada que las transiciones CSS hacen por ti y que con WAAPI hay que reproducir a mano. Sin ella, cerrar un panel que apenas se había abierto tarda la duración completa y se siente mal.
Al terminar se devuelve height: auto. Dejar una altura fija en píxeles es el bug más común de estas implementaciones: el panel se queda con la altura que tenía cuando se abrió, y si el contenido crece después, se recorta.
Las tres trampas
Trampa uno: medir en el momento equivocado. Medir antes de que el elemento se renderice devuelve cero. Si el elemento estaba en display: none, hay que hacerlo visible primero, medir, y solo entonces animar. La secuencia correcta es poner display, medir, poner la altura de partida, animar.
function abrirDesdeNone(el) {
el.style.display = 'block';
el.style.height = 'auto';
const destino = el.getBoundingClientRect().height;
el.style.height = '0px';
el.style.overflow = 'hidden';
return el.animate(
[{ height: '0px' }, { height: `${destino}px` }],
{ duration: DURACION, easing: CURVA }
).finished.then(() => {
el.style.height = 'auto';
el.style.overflow = '';
});
}
Trampa dos: los márgenes que se colapsan. getBoundingClientRect().height devuelve la altura de la caja del borde, sin márgenes. Si el primer o el último hijo tiene margen vertical, ese margen puede colapsar fuera del elemento y no contar en la medida, con lo que el panel abierto acaba midiendo más de lo que mediste. scrollHeight tiene el mismo problema con signo distinto: incluye el relleno pero no los márgenes exteriores.
La corrección estructural es la misma que en la técnica de rejilla: el espaciado va como padding del elemento medido, y los hijos no tienen márgenes verticales. Alternativamente, envuelve el contenido y mide el envoltorio, que sí contiene sus propios márgenes si establece un contexto de formato con display: flow-root.
Trampa tres: la interrupción. Una función que no cancela la animación anterior deja dos animaciones compitiendo por height, y gana la última en el orden de composición, con un salto al empezar. Cancelar sin capturar la posición produce el salto contrario. La versión de arriba hace las dos cosas en el orden correcto.
La medición se presenta siempre como la solución exacta, y lo es en el instante en que mides. El problema aparece después, y es la razón por la que estas implementaciones se rompen en producción sin que nadie toque el código: la altura de auto no es un número, es una función del entorno, y ese entorno cambia sin avisar. Cambia cuando el usuario redimensiona la ventana y el texto refluye. Cambia cuando carga una fuente web con métricas distintas a la de reserva. Cambia cuando una imagen del contenido termina de descargarse. Cambia cuando el usuario aumenta el tamaño de fuente del navegador. Cambia cuando llega contenido nuevo por red. Y cambia cuando el propio contenido tiene una animación que altera su tamaño. En todos esos casos, un panel que dejaste con una altura fija en píxeles se queda recortado o con un hueco, y el síntoma aparece minutos después de la interacción que lo causó, lo que hace la depuración desagradable. La disciplina que lo evita cabe en una frase: la altura en píxeles solo puede existir mientras dura la animación. Antes, el elemento está a cero o a auto; después, vuelve a auto. Nunca se guarda, nunca se cachea, nunca se reutiliza en la siguiente apertura. Si en tu código hay una variable que guarda una altura entre dos interacciones, tienes este bug aunque todavía no lo hayas visto. Y si necesitas que el panel abierto reaccione a cambios de tamaño de su contenido —porque lo hay, y es lo normal—, lo que necesitas no es volver a medir a mano: es que el panel esté en auto cuando no se anima, y dejar que el layout haga su trabajo, que para eso está.
Cuando el contenido cambia estando abierto
Con la disciplina anterior, el panel abierto está en height: auto y se adapta solo. No hace falta nada más.
El caso que sí requiere trabajo es distinto: cuando quieres que el cambio de tamaño también se anime. Un panel abierto al que le llega contenido nuevo y que debería crecer suavemente en lugar de dar un salto.
// Anima los cambios de tamano de un elemento que ya esta abierto.
function observarCrecimiento(el) {
let anterior = el.getBoundingClientRect().height;
const obs = new ResizeObserver(() => {
const actual = el.getBoundingClientRect().height;
if (Math.abs(actual - anterior) < 1) return;
// Anima desde la altura anterior hasta la nueva, sin fijar height.
el.animate(
[{ height: `${anterior}px` }, { height: `${actual}px` }],
{ duration: 200, easing: 'ease-out' }
);
anterior = actual;
});
obs.observe(el);
return () => obs.disconnect();
}
Fíjate en que la animación va de la altura anterior a la nueva sin escribir ninguna altura en el estilo: el elemento sigue en auto y la animación solo tapa visualmente el salto durante 200 milisegundos. Al terminar, el valor animado desaparece y el elemento se queda donde el layout diga, que es exactamente donde debe estar.
Y una advertencia sobre ResizeObserver: como la animación cambia el tamaño del elemento, el observador se dispara de nuevo. La comprobación de un píxel de diferencia y el hecho de que la animación no escriba estilo evitan el bucle, pero conviene probarlo con contenido que cambie deprisa.
La decisión final
| Técnica | Portable | Exacta | Coste | Cuándo |
|---|---|---|---|---|
interpolate-size |
No, solo Chromium | Sí | Layout por fotograma | Como mejora sobre otra |
Rejilla 0fr a 1fr |
Sí | Sí | Layout por fotograma | Elección por defecto |
max-height |
Sí | No, es una apuesta | Layout por fotograma | Contenido acotado, o sin poder tocar el marcado |
| Medición y WAAPI | Sí | Sí | Layout por fotograma más una lectura forzada | Control fino, interrupciones frecuentes |
Las cuatro comparten el coste de fondo y conviene decirlo claro para cerrar el nivel: animar una altura es animar layout, y no hay técnica que lo evite. Ninguna de las cuatro se ejecuta en el compositor, ninguna sobrevive a un hilo principal bloqueado, y todas pagan un layout por fotograma sobre el subárbol afectado.
De ahí sale la última recomendación, que es la más útil de todo el nivel y la que menos se sigue: antes de elegir técnica, comprueba si de verdad necesitas animar la altura. En un porcentaje sorprendente de los casos, lo que comunica la apertura de un panel no es que su altura crezca, sino que su contenido aparece. Y eso se puede hacer con transform y opacity sobre el contenido, con el contenedor cambiando de altura de golpe, en el compositor y a coste cero.
/* El contenedor salta. El contenido entra animado, en el compositor. */
.cuerpo { display: none; }
.desplegable[data-abierto] .cuerpo { display: block; }
.desplegable[data-abierto] .contenido {
animation: entrar 220ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes entrar {
from { opacity: 0; translate: 0 -8px; }
}
Si esa versión comunica lo mismo —y en muchas interfaces lo hace— es objetivamente mejor que las cuatro anteriores, y no tiene ninguna de sus trampas.
- Implementa el mismo desplegable con las cuatro técnicas y con la quinta opción de no animar la altura.
- Cambia el contenido a la mitad y al doble sin tocar el código, y anota cuáles siguen siendo correctas.
- Perfila las cinco con la CPU ralentizada seis veces y anota el peor fotograma de cada una.
- Interrumpe la apertura a mitad en las cuatro que animan altura y anota cuáles se comportan bien.
- Decide cuál llevarías a producción para tu caso y escribe la razón en una frase.