Qué es una animación para el navegador
El modelo único que hay debajo de CSS y de JavaScript: efecto, línea de tiempo y animación, y por qué entenderlo cambia cómo depuras.
Casi todo el mundo aprende animación web al revés: memoriza la sintaxis de transition, luego la de @keyframes, luego descubre element.animate() y cree que son tres tecnologías distintas que hacen lo mismo. No lo son. Desde 2018 los cuatro motores implementan un único modelo interno —el del estándar Web Animations— y tanto CSS como JavaScript son fachadas que construyen los mismos tres objetos. Esta lección instala ese modelo antes de que escribas una línea, porque es el que explica los comportamientos que de otra forma parecen arbitrarios.
- Describir los tres objetos del modelo de animación y qué responsabilidad tiene cada uno.
- Explicar por qué una animación CSS aparece en
document.getAnimations(). - Distinguir entre el valor que escribes en la hoja de estilos y el valor efectivo durante una animación.
- Justificar por qué el progreso de una animación es un número entre 0 y 1 y qué consecuencias tiene.
Tres objetos y ninguno más
El modelo tiene tres piezas y todas las APIs del navegador se reducen a ellas.
Una línea de tiempo (AnimationTimeline) es una fuente de tiempo monótona. Su única obligación es responder a la pregunta “¿cuánto tiempo ha pasado?” con un número o con null si todavía no está activa. La línea de tiempo por defecto es la del documento: empieza a contar cuando el documento empieza a existir y avanza en milisegundos. Que sea un objeto separado y sustituible es lo que hace posible que exista todo el capítulo de animaciones dirigidas por scroll: ahí la fuente de tiempo no es el reloj, es la posición de un scroller, y el resto del sistema no se entera de la diferencia.
Un efecto (AnimationEffect, y en la práctica siempre KeyframeEffect) es una función pura del progreso. Recibe un número y devuelve un conjunto de valores de propiedades. No sabe qué hora es, no sabe si está pausado, no sabe nada del elemento más allá de a quién tiene que aplicar el resultado. Contiene los fotogramas clave y el timing: duración, retraso, iteraciones, dirección, modo de relleno y función de suavizado.
Una animación (Animation) es el pegamento: conecta un efecto con una línea de tiempo y añade el estado de reproducción. Aquí viven play(), pause(), currentTime, playbackRate y la promesa finished. Una animación sin línea de tiempo es un objeto perfectamente válido que simplemente no avanza.
La separación no es un capricho de arquitectura. Es la razón de que puedas coger una animación declarada en CSS y pausarla desde JavaScript sin haberla creado desde JavaScript, y de que el mismo efecto pueda reproducirse hacia atrás sin reescribir los fotogramas.
// Una animacion declarada integramente en CSS, controlada desde JS.
const caja = document.querySelector('.caja');
const [anim] = caja.getAnimations();
anim.pause();
anim.currentTime = 500; // milisegundos dentro del efecto
anim.playbackRate = -1; // ahora corre hacia atras
anim.play();
anim.finished.then(() => console.log('terminada'));
Ese getAnimations() devuelve objetos Animation reales para las animaciones CSS y para las transiciones CSS, no envoltorios simulados. Una animación declarada con @keyframes es internamente una CSSAnimation, que hereda de Animation. Una transición es una CSSTransition. Ambas se comportan como cualquier animación creada con element.animate(), con una diferencia que importa: el motor las mantiene sincronizadas con la cascada, y si el estilo que las originó desaparece, se cancelan.
El progreso es un número entre 0 y 1
Dentro del efecto, el tiempo no existe. Lo que existe es una fracción de progreso. El motor calcula esa fracción en tres pasos encadenados y conviene tenerlos separados en la cabeza porque cada uno tiene sus propias trampas.
Primero, del tiempo de la línea de tiempo se descuenta la hora de inicio y se obtiene el tiempo local. Segundo, del tiempo local se descuenta el retraso, se divide por la duración y se aplica el número de iteraciones y la dirección: sale el progreso de iteración, un número en el intervalo cerrado de 0 a 1. Tercero, ese progreso pasa por la función de suavizado y sale el progreso transformado, que es el que de verdad se usa para interpolar.
La consecuencia es que la función de suavizado no deforma el tiempo: deforma el progreso. Cuando escribes ease-out, no estás diciendo “que el reloj vaya más despacio al final”, estás diciendo “cuando el reloj vaya por la mitad, el movimiento ya debe ir por el 80 por ciento”. El elemento llega al final exactamente cuando termina la duración, siempre, sea cual sea la curva.
Y por eso el progreso transformado puede salirse del intervalo de 0 a 1 aunque el de iteración no pueda. Una curva de Bézier con un punto de control por encima de 1 produce progresos de 1.15, y eso es lo que genera el rebote: el valor interpolado se pasa del destino y vuelve. No hay ninguna magia adicional; es aritmética sobre un número que se ha salido de rango a propósito.
// El mismo efecto, dos suavizados. Ambos duran 400 ms exactos.
const opciones = { duration: 400, fill: 'forwards' };
caja.animate([{ translate: '0' }, { translate: '200px' }],
{ ...opciones, easing: 'ease-out' });
// Se pasa de 200px y vuelve: el progreso transformado supera 1.
caja.animate([{ translate: '0' }, { translate: '200px' }],
{ ...opciones, easing: 'cubic-bezier(.34, 1.56, .64, 1)' });
Esta es la confusión que más horas cuesta. Cuando una animación está corriendo, el valor que ves en pantalla no se obtiene de la cascada: se calcula aparte y se inyecta en un origen propio, por encima de todo lo demás. La especificación lo llama origen de animación, y está literalmente por encima de las declaraciones de autor con !important en el caso de las animaciones CSS. Por eso pasan tres cosas que parecen bugs y no lo son. Primera: mientras una animación anima transform, puedes cambiar transform en la hoja de estilos y no ocurre nada visible, porque el valor animado gana. Segunda: getComputedStyle(el).transform durante una animación devuelve el valor animado de ese instante, no el de la hoja de estilos, así que si lo lees para “guardarlo” te llevas un fotograma intermedio congelado. Tercera, y la peor: cuando la animación se cancela o termina sin fill, el valor animado desaparece de golpe y el elemento salta al valor de la cascada. Ese salto no es un fallo de tu curva, es el origen de animación retirándose. Si necesitas que el estado final persista, no basta con “que la animación acabe ahí”: o usas fill: 'forwards' —que mantiene el origen de animación vivo indefinidamente y por tanto sigue tapando la cascada— o llamas a commitStyles() para escribir el valor final como estilo en línea y luego cancel() para soltar el origen. La segunda opción es casi siempre la correcta, porque deja el elemento en un estado que la cascada puede volver a gobernar.
Qué significa esto para depurar
Un modelo mental sirve si convierte preguntas confusas en preguntas concretas. Con estas tres piezas, cualquier “no se anima” se descompone en cuatro preguntas ordenadas, y responderlas en orden ahorra la mayor parte del tiempo perdido.
¿Existe la animación? Comprueba el.getAnimations().length. Si es cero, el problema no está en la animación: está en que nunca se creó. Con CSS eso significa que la regla no se aplicó, o que el cambio de estilo no llegó a producirse como cambio.
¿Tiene línea de tiempo y está avanzando? Mira anim.timeline y anim.currentTime. Si currentTime es null, la animación está inactiva. Si no avanza entre dos lecturas, la línea de tiempo está parada, y con animaciones de scroll eso es lo normal cuando el scroller no es el que creías.
¿Está interpolando lo que crees? anim.effect.getKeyframes() devuelve los fotogramas ya normalizados por el motor, con los desplazamientos calculados y las propiedades rellenadas. Es frecuente descubrir aquí que una propiedad que dabas por animada no aparece, porque su valor no era interpolable.
¿Está el resultado llegando a la pantalla? Esa es ya otra pregunta, y pertenece al pipeline de renderizado, no al modelo de animación.
// Radiografia completa de las animaciones de un elemento.
for (const a of el.getAnimations()) {
console.log({
tipo: a.constructor.name, // Animation | CSSAnimation | CSSTransition
estado: a.playState, // idle | running | paused | finished
tiempo: a.currentTime,
linea: a.timeline?.constructor.name,
fotogramas: a.effect.getKeyframes(),
timing: a.effect.getComputedTiming(),
});
}
getComputedTiming() merece atención aparte: devuelve la duración ya resuelta, el progreso actual y el índice de iteración. Cuando una animación CSS parece durar más de lo que pusiste, ese objeto te dice la duración real que el motor calculó, incluyendo las iteraciones y el retraso.
- Declara en CSS una animación con
@keyframesde tres segundos sobre un elemento y no la toques desde JavaScript. - Desde la consola, obtén la animación con
getAnimations(), pausa a mitad y ponplaybackRate = 0.25. Confirma que sigue siendo la misma animación CSS y no una nueva. - Lee
getComputedStyle(el).translatemientras está pausada y compáralo con lo que dice la hoja de estilos. Anota qué gana. - Llama a
cancel()y observa el salto. Repite concommitStyles()antes de cancelar y explica la diferencia.