Element.animate: la puerta de entrada a WAAPI
Qué construye exactamente esa llamada, por qué la animación arranca sola, y por qué no aparece ni en el CSSOM ni en el estilo en línea del elemento.
Element.animate() no es un envoltorio de conveniencia sobre CSS ni una reimplementación de jQuery.animate con mejor rendimiento. Es la forma corta de construir tres objetos —un efecto, una animación y su vínculo con una línea de tiempo— y ponerlos en marcha. Entender qué construye, y dónde vive lo que construye, explica por qué las animaciones creadas así no aparecen en el estilo del elemento, por qué ganan a las animaciones CSS, y por qué desaparecen solas al terminar.
- Describir los tres objetos que crea una llamada a
animate(). - Explicar por qué la animación empieza sin llamar a
play(). - Comprobar que los valores animados no están en
element.style. - Predecir qué ocurre al llamar dos veces sobre la misma propiedad.
La firma y lo que hay debajo
const barra = document.querySelector('.barra');
const anim = barra.animate(
[ { transform: 'scaleX(0)' }, { transform: 'scaleX(1)' } ],
{ duration: 600, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' }
);
Dos argumentos: los keyframes y las opciones de temporización. La especificación define el método de forma casi literal como esta secuencia:
// Lo que animate() hace por dentro, escrito a mano
const efecto = new KeyframeEffect(barra, keyframes, opciones);
const anim = new Animation(efecto, document.timeline);
anim.play();
Tres objetos con responsabilidades separadas. El KeyframeEffect sabe qué valores producir para un progreso dado y sobre qué elemento aplicarlos; no sabe nada de tiempo real. La Animation conecta ese efecto con una línea de tiempo y gestiona el estado de reproducción. La DocumentTimeline, accesible como document.timeline, es la fuente del tiempo.
Esa separación no es burocracia de especificación: es lo que permite reutilizar un efecto en varios elementos, sustituir la línea de tiempo de una animación en marcha, o tener una animación construida y sin reproducir. animate() te ahorra escribirlo cuando no necesitas nada de eso, y las tres piezas siguen ahí para cuando sí.
El método está definido en el mixin Animatable, que implementan Element y CSSPseudoElement. Cualquier elemento lo tiene, incluido document.body y los elementos SVG.
Arranca sola, y eso es una decisión
La última línea de la secuencia de arriba es anim.play(). animate() inicia la animación; no la deja preparada. Si querías construirla sin reproducirla, la vía es el constructor explícito:
const efecto = new KeyframeEffect(barra, keyframes, opciones);
const anim = new Animation(efecto, document.timeline);
// playState es 'idle'; no ocurre nada hasta que llames a play()
La decisión de que el atajo arranque tiene sentido: el caso abrumadoramente mayoritario es “anima esto ahora”, y obligar a un .play() en cada llamada sería ruido. Pero conviene saber que la animación devuelta ya está en marcha, porque afecta a cómo se leen sus propiedades justo después de crearla.
En concreto, inmediatamente después de animate() la animación está pendiente: anim.pending vale true y anim.startTime es null, porque el instante de inicio se resuelve en el siguiente frame, cuando el navegador puede sincronizarla con el compositor. La propiedad anim.ready es una promesa que se cumple justo en ese momento. Leer startTime en la línea siguiente a animate() y obtener null no es un fallo: es el modelo funcionando.
No toca el DOM
Lo que más desconcierta al llegar desde librerías clásicas es que la animación no deja rastro en el elemento:
const caja = document.querySelector('.caja');
caja.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 1000, fill: 'both' });
setTimeout(() => {
console.log(caja.style.opacity); // "" (cadena vacia)
console.log(getComputedStyle(caja).opacity); // "0.5" aprox
}, 500);
element.style sigue vacío porque el estilo en línea es una fuente de declaraciones y la animación no es una declaración: es un participante distinto de la cascada, con su propio escalón. getComputedStyle sí refleja el valor porque el valor computado es el resultado de toda la cascada, animaciones incluidas.
Esto tiene tres consecuencias muy prácticas. La primera es que no hay estilos que limpiar: cuando la animación termina y se retira, el elemento vuelve solo a su estado de cascada. La segunda es que no se invalida ninguna hoja de estilo: insertar una regla en un CSSStyleSheet obliga al motor a reevaluar los selectores contra el documento, mientras que crear una animación solo afecta a su elemento. Con animaciones generadas en bucle, la diferencia de coste es enorme.
La tercera es que si necesitas el valor en el DOM —para serializarlo, para que sobreviva a un cambio de clase, para pasárselo a otra cosa— tienes que pedirlo explícitamente con anim.commitStyles(), que escribe el valor computado del efecto en el estilo en línea.
Dos llamadas sobre la misma propiedad
Cada llamada a animate() crea una animación nueva e independiente. No se reemplaza la anterior, no se encola, no se fusiona.
caja.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(200px)' }], 1000);
caja.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], 1000);
Las dos corren a la vez, y como ambas escriben transform, gana la segunda: el elemento gira y no se desplaza. El orden de composición de las animaciones sin dueño CSS es el orden de creación, y la última sobrescribe a las anteriores. Esto es idéntico a lo que pasa con dos animaciones CSS sobre el mismo elemento, y tiene la misma solución, que es componer en vez de pisar con composite.
Si lo que querías era sustituir la animación anterior, hay que quitarla:
caja.getAnimations().forEach((a) => a.cancel());
caja.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(200px)' }], 1000);
Ojo con que getAnimations() en un elemento devuelve también las animaciones CSS y las transiciones activas, así que ese cancel() indiscriminado se lleva por delante más de lo que crees. Filtrar por a.id, que puedes fijar en las opciones, es la forma limpia de saber cuáles son tuyas.
Una animación creada con animate() gana siempre a una animación CSS sobre la misma propiedad, sin importar el orden ni la especificidad del selector. El orden de composición que define la especificación es: primero las transiciones CSS, después las animaciones CSS, y por último las animaciones que no tienen un dueño en el CSS, es decir, las creadas desde JavaScript. Cada grupo pisa al anterior. Esto es deliberado y es lo que hace utilizable la API: si tu animación imperativa perdiera contra una regla @keyframes de una hoja de estilo cargada después, no podrías confiar en ella nunca. El efecto secundario es que WAAPI es la única forma de anular una animación CSS de un tercero sobre una propiedad concreta sin recurrir a !important, que es un martillo que se lleva por delante también tu propia animación. Y explica un fallo desconcertante en la dirección contraria: si añades una clase con una animación CSS mientras una animación WAAPI con fill: forwards sigue rellenando la misma propiedad, la clase no hace nada visible, porque la animación de JavaScript está por encima aunque terminase hace diez segundos.
Aplica a un elemento una animación CSS de background-color con infinite, y desde la consola lanza el.animate([{backgroundColor: 'red'}], {duration: 2000, fill: 'forwards'}). Comprueba que la animación CSS sigue corriendo —lo verás en el.getAnimations()— pero que el color que se ve es el de JavaScript. Después cancela la de JavaScript y observa cómo la CSS reaparece exactamente donde iba, porque nunca dejó de avanzar.