La timeline de progreso del scroll
Qué es exactamente una scroll progress timeline, cómo se conecta con animation-timeline, y por qué la duración deja de medirse en segundos.
Una animación tiene dos piezas independientes: qué cambia y qué la hace avanzar. Durante veinticinco años lo segundo fue siempre el reloj, hasta el punto de que la mayoría de la gente no distingue las dos cosas. La función scroll() sustituye el reloj por la posición de una barra de desplazamiento, y ese cambio de fuente de progreso es todo lo que hay: los keyframes son los mismos, el easing es el mismo, y el motor sigue componiendo en el mismo hilo. Lo único distinto es de dónde sale el número entre cero y uno.
- Describir qué mide una scroll progress timeline y entre qué dos posiciones se normaliza.
- Escribir una animación dirigida por scroll con
animation-timeline: scroll(). - Explicar por qué
animation-durationdeja de significar segundos y qué hay que poner en su lugar. - Reconocer cuándo una timeline está inactiva y qué le pasa entonces a la animación.
Qué mide exactamente
Una scroll progress timeline toma un contenedor con desbordamiento —un scroller— y convierte su posición de desplazamiento en un progreso normalizado. El 0% es el principio del recorrido scrollable en el eje elegido; el 100% es el final. No hay nada más. El recorrido scrollable es la diferencia entre el tamaño del contenido desbordado y el tamaño visible del contenedor: para el documento, scrollHeight - clientHeight.
Esa normalización tiene una consecuencia que conviene interiorizar antes de escribir nada: la timeline no sabe cuántos píxeles hay. Si el artículo mide tres mil píxeles o treinta mil, el 50% sigue siendo la mitad del recorrido. Eso es exactamente lo que quieres para una barra de progreso y exactamente lo que no quieres si tu animación tenía que durar “doscientos píxeles de scroll”. Para lo segundo existe la otra familia de timelines, la de vista, que se ancla a la geometría de un elemento concreto.
La conexión con la animación se hace con una sola propiedad. animation-timeline acepta, además de auto y none, la notación funcional scroll(), que crea una timeline anónima:
@keyframes girar {
to { rotate: 1turn; }
}
.aguja {
animation-name: girar;
animation-timing-function: linear;
animation-duration: auto;
animation-timeline: scroll();
}
Sin argumentos, scroll() significa scroll(nearest block): el ancestro más cercano que sea un contenedor de scroll en el eje de bloque. Si no hay ninguno, el ancestro es el propio viewport del documento, porque las referencias al elemento raíz se propagan al viewport. En la práctica, esas siete letras cubren el caso más común —“según se scrollea la página”— sin más ceremonia.
Fíjate en animation-timing-function: linear. Sobre una timeline de scroll casi siempre lo quieres, y el motivo no es estético: el easing por defecto, ease, ya introduce aceleración y desaceleración, y el usuario ya está aplicando su propia curva con el dedo o la rueda. Superponer las dos produce esa sensación de gelatina que delata las animaciones de scroll mal hechas. El movimiento debe ir pegado al dedo; la curva la pone la persona.
Las animaciones dirigidas por scroll están en Chromium desde la versión 115 y en Safari desde la 26. Firefox no las ha implementado: existen tras el flag layout.css.scroll-driven-animations.enabled en about:config, pero no están activas para el público. Eso significa que todo lo de este nivel y del siguiente hay que escribirlo pensando desde el principio en qué se ve cuando la timeline no existe. No es un detalle que se arregla al final: condiciona cómo escribes los keyframes. El nivel 17 entero va de eso.
El tiempo deja de medirse en segundos
Aquí es donde se rompen la mayoría de los primeros intentos. Cuando una animación se engancha a una timeline de progreso, animation-duration sigue existiendo pero ya no habla de tiempo real, y su valor inicial —auto— pasa a significar “ocupa la timeline entera”. Esto es lo que quieres en la inmensa mayoría de los casos.
Si escribes un tiempo concreto, la animación deja de cubrir el recorrido completo y se comprime en una fracción del mismo, con lo que el efecto termina en los primeros centímetros de scroll y el resto de la página no hace nada. El síntoma clásico es “mi animación se completa al instante y luego se queda quieta”. La cura es quitar el tiempo.
El corolario es que animation-delay tampoco sirve para retrasar el arranque en el eje del scroll. Lo que corresponde es acotar el tramo de la timeline en el que la animación está activa, y eso se hace con animation-range, que es territorio del nivel siguiente porque cobra todo su sentido con las timelines de vista.
Lo que sí se conserva íntegro es el resto del sistema. animation-fill-mode funciona igual y suele hacer falta: fuera del rango activo, sin fill-mode, la animación no aplica ningún valor y el elemento vuelve a su estilo base de golpe. animation-direction, animation-iteration-count y los porcentajes de los keyframes se comportan como siempre, solo que “el 30% de la animación” ahora quiere decir “el 30% del recorrido de scroll” en lugar de “el 30% de los dos segundos”.
/* Encoge y difumina una cabecera en el primer tramo de la pagina */
@keyframes compactar {
to {
block-size: 3.5rem;
background-color: oklch(20% 0.02 260 / 0.9);
backdrop-filter: blur(8px);
}
}
header {
position: sticky;
inset-block-start: 0;
block-size: 7rem;
animation: compactar linear both;
animation-timeline: scroll();
animation-range: 0 240px;
}
Ese animation-range: 0 240px es la forma correcta de decir “esto ocurre en los primeros doscientos cuarenta píxeles de scroll”: no se toca la duración, se acota el rango sobre la timeline.
Timelines inactivas y el fallo silencioso
Una timeline puede existir en el CSS y aun así no producir ningún progreso. La especificación lo llama inactive timeline y ocurre en varios casos perfectamente cotidianos: el scroller referenciado no desborda todavía, no es scrollable en el eje pedido, o el nombre al que apuntas no resuelve a ninguna timeline.
Cuando la timeline está inactiva, la animación no produce ningún valor de efecto. No se queda en el primer keyframe: sencillamente no aplica. El elemento se ve con su estilo base. Esto es coherente con el modelo de Web Animations —una animación sin tiempo actual no tiene efecto— y es la razón por la que un opacity: 0 en la regla base es tan peligroso en este terreno: si por lo que sea la timeline no arranca, el contenido es invisible y no hay forma de recuperarlo.
El caso que más desconcierta es el del scroller que aún no desborda. Imagina una tarjeta con overflow: auto cuyo contenido depende de datos que llegan por red. En el primer frame no hay desbordamiento, la timeline está inactiva y la animación no pinta nada. Cuando llegan los datos y el contenido crece, la timeline pasa a estar activa. La especificación resuelve esa transición con cuidado: las timelines recién creadas o cuyos rangos han cambiado se marcan como stale y el motor hace una ronda adicional de recálculo de estilo y layout dentro del mismo frame, precisamente para que no se vea un fogonazo del estado sin animar.
Piensa en lo que acabas de construir: el scroll determina el progreso, el progreso determina el estilo, y el estilo puede determinar el tamaño del contenido, que determina el recorrido scrollable, que determina el progreso. Es un bucle de realimentación de verdad, y si lo dejas suelto tienes un contenedor que oscila para siempre a sesenta hercios. La especificación lo corta con una regla dura: las animaciones con timeline de progreso actualizan su tiempo actual una sola vez por frame, en un punto fijo del bucle de eventos, antes de calcular estilo. Si tu animación cambia el tamaño del contenido, ese cambio no se reinyecta en el progreso hasta el frame siguiente. La consecuencia práctica es doble. Primero: es imposible provocar un bucle infinito, aunque animes height con la timeline del propio scroller. Segundo, y menos agradable: si tu animación altera la altura del contenido, el progreso que ves va un frame por detrás de la realidad, y con scroll rápido eso se manifiesta como un desfase de uno o dos píxeles entre la barra de progreso y el contenido. No es un bug del navegador, es el precio de haber roto el ciclo. La forma de no sufrirlo es la de siempre: anima transform, opacity y filter, que no cambian el layout y por tanto no cierran el bucle. Cuando alguien te enseñe una barra de progreso que “tiembla”, mira si está animando width en lugar de scaleX.
Lo que esto elimina
Vale la pena ser concreto sobre qué código desaparece. La versión en JavaScript de la animación de la cabecera de arriba, escrita con el cuidado que merece, incluye un listener de scroll con passive: true, un requestAnimationFrame para no leer layout en el handler, una lectura de scrollTop y de scrollHeight que fuerza reflow si no la colocas bien, una división para normalizar, una interpolación manual entre los dos valores, la escritura del estilo, y un ResizeObserver para recalcular los límites cuando cambia el tamaño de la ventana. Son unas cuarenta líneas que hay que mantener y que corren en el hilo principal.
La versión declarativa son tres líneas de CSS y corre donde el motor decida, que en Chromium y Safari es el hilo del compositor si las propiedades animadas lo permiten. Esa es la ganancia real: no es que escribas menos, es que la animación deja de competir con tu JavaScript. Una animación de scroll declarativa sobre transform sigue siendo fluida mientras el hilo principal está bloqueado hidratando un componente; la versión con listener, por definición, no.
La contrapartida honesta es que pierdes la capacidad de hacer cualquier cosa que no sea interpolar propiedades. Si necesitas disparar una petición de red al llegar al 60% del artículo, o reproducir un vídeo, o cargar una imagen, sigues necesitando JavaScript, y para eso lo correcto es IntersectionObserver, no un listener de scroll.