Los eventos finish y cancel, y el control que CSS no da
Cuándo un evento dice algo que la promesa no, cómo volcar el resultado de una animación al DOM con commitStyles, y tres casos donde la API imperativa es la única salida.
Las promesas responden una vez; los eventos responden todas. Esa es toda la diferencia entre finished y el evento finish, y determina cuál usar en cada sitio. Con los dos mecanismos y los métodos de las lecciones anteriores ya tienes el objeto Animation completo, y conviene cerrar el nivel con lo que de verdad justifica salir del CSS: los tres o cuatro problemas de interfaz que no tienen solución declarativa por mucho que se estire la sintaxis.
- Elegir entre el evento
finishy la promesafinishedsegún el caso. - Volcar el estado final de una animación al DOM con
commitStyles(). - Implementar una interacción reversible que conserve el progreso.
- Reconocer los problemas que solo se resuelven con control imperativo.
finish, cancel y por qué no bastan las promesas
Animation es un EventTarget. Dispara finish cada vez que entra en estado 'finished' y cancel cada vez que se cancela una animación que no estaba ya en 'idle'.
const anim = el.animate(kf, { duration: 800, iterations: 4 });
anim.addEventListener('finish', () => console.log('terminada'));
anim.addEventListener('cancel', () => console.log('cancelada'));
// o con las propiedades: anim.onfinish = ..., anim.oncancel = ...
La diferencia con las promesas es que un evento se dispara cada vez. Una animación de larga vida que se reproduce, se rebobina y se vuelve a reproducir dispara finish en cada ciclo, mientras que la promesa finished se sustituye por otra nueva y hay que volver a leerla.
De ahí sale la regla de uso, que es clara:
- Para una espera puntual dentro de un flujo secuencial, usa
await anim.finished. Es más legible y no deja escuchadores que limpiar. - Para reaccionar siempre que algo termine, sobre una animación que se reutiliza, usa el evento. No hay forma limpia de hacerlo con promesas sin reengancharse tras cada ciclo.
El objeto de evento es un AnimationPlaybackEvent y trae dos campos útiles: currentTime y timelineTime, ambos en el instante del disparo. Sirven para registrar con precisión cuándo ocurrió, sin volver a consultar la animación, que para entonces ya podría haber cambiado.
Los eventos de animación de CSS —animationstart, animationend, animationiteration, transitionend— siguen existiendo y se disparan sobre el elemento, no sobre la animación, y burbujean. Los de WAAPI se disparan sobre el objeto Animation y no burbujean, porque no están en el árbol. Es la razón por la que no hay conflicto entre unos y otros aunque una CSSAnimation dispare ambos.
commitStyles: sacar el resultado al DOM
Ya vimos que una animación con relleno bloquea la cascada indefinidamente. commitStyles() es la salida: escribe en el estilo en línea del elemento los valores computados que el efecto está produciendo en ese instante, para todas las propiedades que anima.
const anim = el.animate(
{ transform: ['none', 'translateX(240px) rotate(15deg)'] },
{ duration: 600, fill: 'forwards' }
);
await anim.finished;
anim.commitStyles(); // el.style.transform pasa a valer el resultado final
anim.cancel(); // la animacion desaparece de la cascada
Tras esas dos líneas el elemento se queda exactamente donde estaba, pero por una razón distinta: ahora el valor está en el.style, que es una declaración normal que puedes leer, sobrescribir o quitar. La animación ya no existe y no bloquea nada.
Es la única forma correcta de terminar una animación cuyo valor final no se puede escribir a mano, que es el caso de las iteraciones fraccionarias, los keyframes implícitos y cualquier valor calculado en tiempo de ejecución.
Dos cosas que hay que saber. La primera es que commitStyles() lanza una excepción si el elemento no está siendo renderizado —desconectado del DOM, o con display: none— porque no hay valor computado que volcar. La segunda es que escribe en el estilo en línea, con la especificidad máxima que eso implica, así que a partir de ese momento ninguna regla de la hoja de estilo podrá cambiar esas propiedades sin !important. Si el elemento va a seguir vivo mucho tiempo, conviene limpiar el.style.transform = '' cuando el valor deje de hacer falta.
Reversible sin saltos
Este es el caso que más se nota en una interfaz y el que peor se resuelve con CSS. Un elemento que se expande al pasar el ratón y se contrae al salir. Con transiciones CSS sale gratis. Pero en cuanto la animación tiene varios keyframes, o una curva que no es simétrica, o un filter que debe ir con otra curva, ya no es una transición y necesitas keyframes; y con keyframes, salir a mitad de camino reinicia desde el principio.
function hoverReversible(el, keyframes, duracion = 320) {
const anim = el.animate(keyframes, { duration: duracion, easing: 'ease-out', fill: 'both' });
anim.pause();
anim.currentTime = 0;
el.addEventListener('pointerenter', () => { anim.playbackRate = 1; anim.play(); });
el.addEventListener('pointerleave', () => { anim.playbackRate = -1; anim.play(); });
el.addEventListener('focus', () => { anim.playbackRate = 1; anim.play(); });
el.addEventListener('blur', () => { anim.playbackRate = -1; anim.play(); });
return anim;
}
hoverReversible(document.querySelector('.tarjeta'), {
transform: ['none', 'translateY(-6px) scale(1.03)'],
boxShadow: ['0 1px 2px rgba(0,0,0,.2)', '0 12px 28px rgba(0,0,0,.35)'],
filter: ['saturate(1)', 'saturate(1.15)'],
});
Sale del ratón a mitad de la expansión y la tarjeta vuelve desde donde estaba, con la duración proporcional al camino que le quede. Entra otra vez y sigue hacia adelante. No hay cálculo de progreso, no hay duraciones ajustadas a mano, no hay estado externo. La conservación del progreso la da gratis el hecho de que hay una sola animación cuyo tiempo actual nunca se reinicia.
El detalle que lo hace correcto y no un apaño es anim.pause() seguido de currentTime = 0 en la creación: sin eso, la animación arrancaría sola nada más construirla.
Los tres problemas sin solución declarativa
Más allá del hover reversible, hay tres familias de problemas donde el control imperativo no es una preferencia sino un requisito.
El primero es el arrastre: cualquier cosa cuyo progreso lo decida el usuario y no el reloj. Un carrusel que se arrastra, un panel que se desliza siguiendo el dedo, un control de reproducción. La animación tiene que estar detenida y su tiempo escrito desde un evento de puntero, y eso solo se puede hacer con currentTime.
El segundo es la interrupción con conservación de estado: cualquier interacción que pueda cambiar de opinión a mitad. Ya lo hemos visto con el hover; el mismo patrón aplica a un acordeón, a un menú desplegable o a un botón de “me gusta” que se puede pulsar dos veces seguidas.
El tercero es la coordinación entre animaciones que no comparten elemento ni definición. Igualar duraciones ajustando playbackRate, anclar varias animaciones a un startTime común, o pausar todas las de una sección al abrir un modal. El CSS no tiene forma de referirse a “todas las animaciones activas dentro de este contenedor”; getAnimations({ subtree: true }) sí.
Un navegador puede eliminar solo las animaciones rellenadas. La regla, que está en la especificación, es que si una animación con relleno está completamente tapada por otra animación posterior sobre las mismas propiedades, la primera deja de aportar nada útil y el motor la retira para no acumular basura: su replaceState pasa a 'removed' y dispara el evento remove. El motivo es memoria: un patrón de interfaz muy común es crear una animación con fill: 'forwards' en cada interacción, y sin eliminación automática una lista con la que se interactúa mil veces acumula mil animaciones vivas, todas contribuyendo a la cascada, todas evaluadas en cada cálculo de estilo. La retirada las quita cuando ya no cambian nada. Donde muerde es si dependes de una animación rellenada para mantener un estado visual y creas otra encima que después cancelas: al cancelar la segunda, la primera puede haber sido retirada ya, y el elemento salta a su estilo de cascada. anim.persist() marca una animación como exenta de la retirada automática, y es lo que hay que llamar si de verdad necesitas que el relleno sobreviva. Pero casi siempre la respuesta correcta es la otra: no dependas del relleno para mantener estado. Vuelca con commitStyles(), cancela, y deja el estado en el DOM, donde se puede inspeccionar y no depende de la política de retirada de ningún motor.
Implementa el hover reversible sobre una rejilla de doce tarjetas y pasa el ratón rápido por encima de todas. Comprueba con document.getAnimations().length que hay exactamente doce animaciones, una por tarjeta, sin importar cuántas veces hayas entrado y salido. Después reescríbelo creando una animación nueva en cada pointerenter y vuelve a contar: verás la diferencia entre reutilizar un objeto y fabricar basura.