Lo que cambia cuando el tiempo desaparece
fill-mode, eventos, currentTime en porcentaje y el espejo en JavaScript: qué partes del sistema de animación siguen valiendo y cuáles cambian de significado.
Sustituir el reloj por una barra de scroll suena a cambio local y no lo es. Todo el modelo de Web Animations está construido sobre un tiempo que avanza en una sola dirección y a velocidad constante, y cuando esa premisa cae hay propiedades que dejan de tener sentido, eventos que se disparan al revés y una API de JavaScript que devuelve otro tipo de dato. Esta lección recorre esas costuras, que es donde aparecen los bugs que no se explican leyendo solo la sintaxis.
- Predecir el estado visual de un elemento fuera del rango activo según su
fill-mode. - Anticipar el orden en que se disparan los eventos de animación al scrollear hacia atrás.
- Construir la misma animación desde JavaScript con
ScrollTimeline. - Decidir cuándo conviene la versión CSS y cuándo la versión imperativa.
fill-mode deja de ser un detalle
Con una animación de reloj, olvidarse de animation-fill-mode produce un parpadeo al final: la animación acaba, el elemento vuelve a su estilo base, y si el último keyframe difiere del estilo base se ve un salto. Es molesto y se corrige tarde.
Con una timeline de progreso, olvidarse de fill-mode produce algo peor: el elemento pasa la mayor parte del tiempo fuera del rango activo. Piensa en una animación acotada con animation-range: 0 240px. Del píxel 241 en adelante —que puede ser el noventa y cinco por ciento del documento— la animación no está activa. Sin fill-mode, en cuanto pasas ese umbral el elemento revierte de golpe a su estilo base y la cabecera que acababas de compactar se despliega otra vez.
La regla operativa es simple: en animaciones dirigidas por scroll, fill-mode: both es el valor por defecto que deberías escribir siempre, salvo que tengas un motivo explícito para lo contrario. forwards mantiene el último keyframe después del rango, backwards mantiene el primero antes del rango, y both hace las dos cosas, que es lo que quiere decir “esta animación describe el estado del elemento en función del scroll”.
Hay un matiz que se olvida y explica el fallo justo al cargar la página: backwards no solo aplica antes del rango, aplica también durante el retardo, y en un scroll que arranca a mitad de página —al llegar con un enlace de ancla, o al restaurar la posición al recargar— el elemento nace ya dentro o después del rango. Sin both, ese primer frame es el estilo base y se ve el salto exactamente una vez, justo al cargar, que es cuando menos ganas tienes de estar depurando.
Los eventos se disparan al revés
Las animaciones dirigidas por scroll emiten los mismos eventos que cualquier otra: animationstart, animationend, animationiteration, y los de Web Animations. Lo que cambia es cuándo.
La especificación lo dice sin rodeos: al scrollear hacia atrás, animationstart se dispara al final del intervalo activo y animationend al principio. Tiene toda la lógica del mundo si lo piensas —vas recorriendo la timeline en sentido inverso, así que entras por donde antes salías— y ninguna si tu código asumía que animationstart significa “esto empieza ahora”.
const cabecera = document.querySelector('header');
cabecera.addEventListener('animationstart', () => {
// Se dispara al entrar en el rango, en cualquiera de los dos sentidos.
cabecera.dataset.compacta = 'si';
});
cabecera.addEventListener('animationend', () => {
// Se dispara al salir del rango, en cualquiera de los dos sentidos.
delete cabecera.dataset.compacta;
});
Ese código hace lo correcto porque trata los dos eventos como “entrar” y “salir” del rango, no como “empezar” y “acabar”. Cualquier lógica que dependa de la dirección tiene que preguntar por la dirección explícitamente, no deducirla del evento.
Hay una excepción interesante: el evento finish de Web Animations solo se dispara al scrollear hacia delante, porque finish no habla del final de la timeline sino de entrar en el estado de reproducción “finished”, y hacia atrás no se entra en ese estado. Es la clase de asimetría que solo se descubre cuando algo no ocurre y no sabes por qué.
En el nivel de WAAPI aprendiste que animation.finished es una promesa y que encadenar animaciones con ella es limpio y correcto. Sobre una timeline de scroll, esa promesa se convierte en una trampa. El motivo es que las promesas se resuelven una sola vez y una animación de scroll entra y sale del estado terminado tantas veces como el usuario decida subir y bajar. La primera vez que el usuario llega al final, la promesa se resuelve y tu callback corre; la segunda vez no ocurre nada, porque una promesa resuelta ya no vuelve a resolverse. Peor: si el usuario vuelve a subir, la animación deja de estar terminada, la especificación sustituye el objeto promesa por uno nuevo pendiente, y cualquier referencia que tuvieras guardada a la promesa anterior queda apuntando a un objeto que ya no representa el estado actual. El patrón await animation.finished funciona exactamente una vez y luego se convierte en un await que no resuelve jamás, sin error, sin aviso y sin rastro en el perfilador. Con timelines de progreso, los eventos son la herramienta correcta y las promesas no lo son. Y si lo que quieres es reaccionar a que un elemento entra o sale de la pantalla, la respuesta casi siempre es IntersectionObserver, que fue diseñado exactamente para eso y no tiene ninguna de estas asimetrías.
El espejo en JavaScript
Todo lo anterior tiene su equivalente imperativo. ScrollTimeline es un constructor: se le pasa el elemento fuente y el eje, y el objeto resultante se puede asignar a la propiedad timeline de cualquier animación creada con animate().
const barra = document.querySelector('.progreso');
if ('ScrollTimeline' in window) {
barra.animate(
{ transform: ['scaleX(0)', 'scaleX(1)'] },
{
fill: 'both',
easing: 'linear',
timeline: new ScrollTimeline({
source: document.scrollingElement,
axis: 'block',
}),
}
);
}
/* Equivalente a scroll(root block) con transform-origin en el CSS */
Dos detalles con consecuencias. El primero es que timeline se pasa en las opciones de animate(), no como una propiedad que se asigna después, aunque también se pueda reasignar más tarde. El segundo, más importante, es que sobre una timeline de progreso currentTime deja de ser un número de milisegundos y pasa a ser un valor con unidad de porcentaje. Eso rompe cualquier aritmética que hubieras escrito para timelines de tiempo:
const [anim] = barra.getAnimations();
// Con timeline de tiempo: un numero, en milisegundos.
// Con timeline de progreso: un valor con unidad, hay que leer .value
const t = anim.currentTime;
const porcentaje = typeof t === 'number' ? null : t.value;
Y una propiedad que directamente deja de significar algo: playbackRate. Multiplicar la velocidad de reproducción no tiene sentido cuando el avance lo determina el usuario con el dedo. Puedes asignarla y no producirá el efecto que esperas.
Cuándo cada versión
La pregunta correcta no es “CSS o JavaScript” sino “declarativo o imperativo”, y hay un criterio que decide casi siempre: si el resultado es una interpolación de propiedades, usa CSS; si el resultado es un efecto secundario, usa IntersectionObserver.
La versión CSS gana en el caso común porque el motor puede llevarla al compositor y porque no hay nada que limpiar cuando el elemento desaparece del DOM. La versión con ScrollTimeline en JavaScript gana en tres casos concretos: cuando los keyframes se calculan en tiempo de ejecución a partir de datos, cuando necesitas construir la misma animación sobre muchos elementos con parámetros distintos sin generar CSS, y cuando estás integrando con una librería que ya trabaja con objetos Animation.
Lo que no gana en ningún caso es la tercera vía, la de escuchar scroll y escribir estilos a mano. Esa opción solo tiene sentido hoy como fallback deliberado para el navegador que no implementa timelines, y hasta ahí es discutible: en el nivel 17 veremos que casi siempre es mejor no animar que animar mal.