Scroll-driven animations ya son Baseline: lo que hay que saber antes de usarlas en producción

El 27 de octubre salió Firefox 159 con las scroll-driven animations habilitadas por default. Con Chrome desde la 115 y Safari desde la 26, la feature pasó a ser Baseline Newly available. Doce años después de la primera propuesta, animation-timeline: view() funciona en todos lados.
No voy a contar la historia (la demo "Road to Baseline" lo hace muy bien, y encima se scrollea) ni hacer un tutorial desde cero: para eso está el material de Bramus. Lo que me interesa es otra pregunta: ¿puedo meter esto en producción y olvidarme? Sí, pero con más matices de los que sugiere la tabla de compatibilidad.
Repaso en diez líneas
Por si nunca las usaste: una animación CSS normal avanza con el tiempo. Una scroll-driven avanza con el scroll de un contenedor (scroll()) o con la posición de un elemento dentro de su scrollport (view()).
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
@keyframes reveal {
from { opacity: 0; translate: 0 2rem; }
}
Todo eso sin JavaScript, sin IntersectionObserver y sin listeners de scroll.
Qué es interoperable de verdad
Si mirás los resultados de web-platform-tests en las versiones estables de los tres navegadores, aparece un patrón:
| Área | Chrome | Firefox | Safari |
|---|---|---|---|
CSS (scroll(), view(), animation-range) |
~94% | ~95% | ~94% |
Timelines desde JS (ScrollTimeline) |
~98% | ~53% | ~95% |
ViewTimeline y casos raros |
~96% | ~18% | ~77% |
Son porcentajes de subtests pasados y hay que tomarlos con pinzas, pero el CSS declarativo con timelines anónimos está parejo en los tres. La brecha aparece en la API de JavaScript y en los casos raros de view timelines (elementos sticky, writing modes, insets).
Hay tres zonas donde hoy no me metería sin testear en los tres navegadores:
timeline-scope, que es lo que te permite que un elemento use el timeline de un scroller que no es su ancestro. Es la parte más verde de todas: Chrome sacó el valorallen la 138 (y ahora está en proceso de volver a agregarlo) mientras Firefox y Safari lo soportan, y la forma de buscar nombres de timelines todavía se está discutiendo en el CSSWG. Encima, repetir el mismo nombre de timeline en muchos elementos dispara un caso polinomial feo en Firefox (cerca de N⁴): con mil elementos, el main thread queda bloqueado minutos. Hay fixes en camino para Firefox 161, y Safari tuvo el mismo problema y ya lo corrigió.- Los range names adentro de
@keyframes(entry 0% { … }). Chrome y Safari los soportan, Firefox 159 falla todos los tests. - El control de la reproducción desde JS. En Firefox, tocar
playbackRate,currentTimeostartTimede una animación scroll-driven está lejos de comportarse como en los otros dos.
Yo me quedaría con timelines anónimos en CSS (o, como mucho, un nombre declarado en un ancestro, sin timeline-scope) y range names explícitos. Con eso se cubren casi todos los casos de UX que vas a necesitar, y es lo que mejor anda en los tres.
Baseline no significa mismo rendimiento
Uno de los argumentos fuertes de las scroll-driven animations es que pueden correr fuera del main thread. Lo que los anuncios no cuentan es que depende mucho del navegador:
| Motor | scroll() |
view() |
|---|---|---|
| Chrome (115+) | Compositor | Compositor |
| Safari 26.0–26.3 | Main thread, a 60 Hz | Main thread, a 60 Hz |
| Safari 26.4+ | Threaded | Threaded |
| Firefox 159 | Compositor | Main thread |
Safari 26 salió con soporte pero sin ninguna aceleración: las animaciones se actualizaban en el main thread, al ritmo del rendering, y a 60 Hz aun en pantallas ProMotion. Recién la 26.4 agregó las animaciones threaded.
Firefox compone las animaciones de scroll() desde hace años, pero las de view() no: el bug 1818346 está en el backlog. O sea que en Firefox el patrón más común de todos, el reveal de un elemento cuando entra en pantalla, corre en el main thread. Sigue siendo mejor que hacerlo con JavaScript (no hay listeners, no hay lecturas de layout, se calcula una vez por frame), pero si el main thread está ocupado se va a notar.
Y aun en Chrome, el compositor solo te ayuda si animás propiedades compositables.
Uno de los case studies anima height
Los casos que más circularon son los de Tokopedia, redBus y Policybazaar, que Chrome publicó como case study. El número que más se repite es el de Tokopedia: el uso promedio de CPU durante el scroll bajó del 50% al 2%.
Si mirás el código que muestran, la barra sticky de Tokopedia anima height de 0 a 48px, y Policybazaar anima font-size y top (además de translate). Ninguna de las tres primeras corre en el compositor: siguen disparando layout y paint en el main thread en cada frame. Las tres empresas también tienen animaciones de cards con opacity y transform, que sí se componen, y el case study no dice qué página ni qué animación se midió. Así que lo más probable es que buena parte de la mejora venga de sacar el handler de JavaScript. Eso no le quita mérito, porque sacar un listener de scroll que trabaja en cada evento es una ganancia enorme, pero esa barra con transform te daría el resto.
Para verificarlo en tu caso, Chrome DevTools tiene en el panel Animations un grupo separado para las scroll-driven, con el timeline en píxeles. En Performance, grabá un scroll con throttling de CPU de 4x o 6x y meté un long task a propósito: si la animación está compuesta, los frames siguen fluidos aunque el main thread esté en rojo. Y en Rendering, activá Paint flashing: si se pinta en cada frame del scroll, estás animando algo que no deberías.
El estado base es tu fallback
Si de este post me tuviera que quedar con una sola regla para un code review, es esta: el estado base del elemento tiene que ser el estado visible. Lo "oculto" va solo en el from de los keyframes.
Hay muchas situaciones en las que la animación no va a correr:
- Un navegador sin soporte (iOS 18 y anteriores, Firefox ESR).
- Un timeline inactivo: el contenedor no tiene overflow, está con
display: none, o el elemento todavía no tiene tamaño. - Impresión: por spec, en paged media los timelines que dependen del viewport (
scroll(root)yview()contra la página) son inactivos. prefers-reduced-motion, si lo respetás (y lo tenés que respetar).
En todos esos casos se ve el estado base. Si tu estado base es opacity: 0, el contenido desaparece. Este es el antipatrón que más se ve en producción:
/* ❌ Así no */
.card { opacity: 0; }
.card {
animation: reveal 1s both;
animation-timeline: view();
}
Tiene dos problemas. El opacity: 0 en el estado base esconde la card si la animación no corre (y si encima tus keyframes no tienen to, como el reveal de arriba, la card no aparece nunca, ni siquiera con soporte: el final de la animación es el estado base, que es opacity: 0). Y sin @supports, un navegador viejo ignora animation-timeline pero sí entiende el shorthand animation: la animación corre por tiempo, una sola vez al cargar la página, todas las cards a la vez. Lo que estaba fuera de pantalla hace su reveal sin que nadie lo vea.
El 1s (o el 1ms que se veía mucho) venía de cuando Firefox tenía la feature detrás de un flag y exigía una duración por tiempo. Hoy no hace falta: el valor inicial de animation-duration es auto, que en un scroll timeline usa toda la duración del rango.
La versión segura:
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
}
}
@keyframes reveal {
from { opacity: 0; translate: 0 2rem; }
/* sin "to": termina en el estado base, que es visible */
}
Fijate que no hay to. Al omitirlo, la animación termina en los valores calculados del elemento, que son los normales. Si mañana cambia el diseño de la card, la animación sigue llegando al lugar correcto.
El orden de las declaraciones importa
Otro error muy común: el shorthand animation resetea animation-timeline y animation-range a sus valores iniciales. Y no hay forma de meter el timeline dentro del shorthand. Pasa en los tres motores. Lo verifiqué en Chrome: con el orden invertido, getComputedStyle() devuelve animation-timeline: auto y la animación queda atada al DocumentTimeline, o sea al reloj.
/* ❌ El shorthand pisa el timeline: la animación corre por tiempo */
.card {
animation-timeline: view();
animation: reveal linear both;
}
/* ✅ Longhands siempre después */
.card {
animation: reveal linear both;
animation-timeline: view();
}
Con CSS-in-JS pasa lo mismo: el orden de las keys del objeto define el orden de las declaraciones.
Tres patrones útiles
No todo lo que se puede animar con el scroll se debe animar. Los patrones que uso son los que comunican estado, como cuánto falta leer o si hay más contenido abajo, y no los que solo decoran.
Progreso de lectura
.reading-progress {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
background: var(--accent);
transform-origin: 0 50%;
display: none; /* sin soporte, no se muestra */
}
@supports (animation-timeline: scroll()) {
.reading-progress {
display: block;
animation: grow linear both;
animation-timeline: scroll(block root);
}
}
@keyframes grow {
from { transform: scaleX(0); }
}
scaleX y no width, para que corra en el compositor. Un detalle: scroll(root) mide todo el documento, footer y comentarios incluidos. Si querés medir solo el artículo, no alcanza con poner view() en la barra, porque toma como subject a la barra misma. Hay que nombrar el timeline en el <article> y poner la barra adentro:
article { view-timeline: --article; }
article .reading-progress {
animation: grow linear both;
animation-timeline: --article;
animation-range: contain;
}
Con un artículo más alto que el viewport, contain va de "empiezo a leer" (el principio del artículo en el borde de arriba) a "terminé" (el final en el borde de abajo), que es justo lo que querés. Con cover, la barra arranca antes de que empieces a leer y nunca llega al 100%. Como el nombre está en un ancestro de la barra, no hace falta timeline-scope.
Header que achica el logo sin animar height
.site-header {
position: sticky;
top: 0;
}
.site-header::after {
content: "";
position: absolute;
inset: auto 0 -8px 0;
height: 8px;
background: linear-gradient(rgb(0 0 0 / 0.15), transparent);
}
@supports (animation-timeline: scroll()) {
.site-header .logo {
display: inline-block; /* scale no se aplica a un inline */
transform-origin: left center;
animation: shrink linear both;
animation-timeline: scroll(root);
animation-range: 0 120px;
}
.site-header::after {
animation: fade-in linear both;
animation-timeline: scroll(root);
animation-range: 0 40px;
}
}
@keyframes shrink { to { scale: 0.7; } }
@keyframes fade-in { from { opacity: 0; } }
Hay tres decisiones en este ejemplo. El rango va en píxeles porque el efecto es "después de 120px de scroll": con porcentajes de scroll(), el 100% depende del largo del documento, y cambia cada vez que carga una imagen sin dimensiones o un bloque lazy. La sombra es un pseudo-elemento al que le animo opacity, en vez de animar box-shadow, que se repinta en cada frame.
La tercera: el header no cambia de alto de verdad, solo se achica el logo. Si querés que el fondo también se vea más bajo, animá un scaleY en un pseudo-elemento de fondo, nunca el height. Si animás el alto real, todo el contenido de abajo se mueve, cambia el tamaño del documento, cambia el rango del timeline, y se arma un feedback loop. Los navegadores lo cortan, pero se nota un temblor.
Sombras de scroll en un contenedor
Para listas o tablas con overflow, mostrar una sombra arriba cuando hay contenido scrolleado y otra abajo cuando queda contenido por ver:
.scroller {
overflow-y: auto;
max-block-size: 24rem;
}
.scroller::before,
.scroller::after {
content: "";
position: sticky;
display: block;
block-size: 12px;
pointer-events: none;
opacity: 0;
z-index: 1;
}
.scroller::before {
inset-block-start: 0;
margin-block-end: -12px;
background: linear-gradient(rgb(0 0 0 / 0.2), transparent);
}
.scroller::after {
inset-block-end: 0;
margin-block-start: -12px;
background: linear-gradient(transparent, rgb(0 0 0 / 0.2));
}
@supports (animation-timeline: scroll()) {
.scroller::before {
animation: show linear both;
animation-timeline: scroll();
animation-range: 0 24px;
}
.scroller::after {
animation: hide linear both;
animation-timeline: scroll();
animation-range: calc(100% - 24px) 100%;
}
}
@keyframes show { to { opacity: 1; } }
@keyframes hide { from { opacity: 1; } to { opacity: 0; } }
Acá el estado base es opacity: 0 y está bien, porque la sombra es un extra. Si el contenido no desborda, el timeline queda inactivo y no hay sombras. En Chrome lo probé en las tres posiciones: arriba solo aparece la sombra de abajo, en el medio las dos y al final solo la de arriba. El calc() en el rango sobre scroll() es de lo que Firefox falla parcialmente en WPT, así que probalo también ahí. Y si es una tabla con el thead sticky, el header viene después en el DOM y tapa la sombra de arriba: ponela debajo del header (inset-block-start con el alto del thead).
Gotchas
Estos son los que me parecen más probables en un proyecto real:
- Un
overflow: hiddenen un ancestro se roba el timeline.scroll()yview()buscan el scroll container más cercano, yoverflow: hiddencrea uno (se puede scrollear por script). El típico wrapper conoverflow-x: hidden"para que no haya scroll horizontal" se vuelve el scroller del timeline, no scrollea nunca, y la animación queda inactiva (en Chrome, elcurrentTimedel timeline danull). Conoverflow: clipno pasa, porque no crea un scroll container. - Porcentajes pelados en
view().animation-range: 10% 40%sobre un view timeline equivale acover 10% cover 40%, pero Firefox falla ese test. Escribí el range name. fill-mode. Sinboth, fuera del rango el elemento vuelve de golpe a su estado base.- Scrollers anidados. Una card dentro de un carrusel con
overflow-x: autoyview()(block) queda atada al carrusel, no a la página: como eloverflow-ycomputa aauto, el carrusel también es scroll container en vertical, no tiene recorrido y la animación queda clavada a la mitad. Si querés que mire la página, ponéoverflow-y: clipen el carrusel (en Chrome anda, en Firefox todavía no). Si querés el carrusel,view(inline). - Los transforms no cuentan para los rangos. El rango de
view()se calcula sobre la posición del elemento sin transform. Evita loops, pero sorprende con parallax. - Custom properties animadas con scroll. Los trucos que animan una
@property --progresscon un scroll timeline para usarla encalc()o en style queries andan bien en Chrome, pero en Firefox hay varios bugs abiertos. No los usaría para algo crítico.
¿Y el polyfill?
El polyfill de flackr sirvió mucho para experimentar estos años, pero su último release es de mayo de 2024 y corre en el main thread con listeners de scroll, que es exactamente lo que queríamos evitar.
Yo lo sacaría. Para una mejora visual, la degradación correcta es "sin animación", no "animación por JavaScript". Lo mantendría solo si la animación transmite información que el producto necesita, y aun así cargándolo de forma condicional:
if (!CSS.supports('animation-timeline: scroll()')) {
await import('scroll-timeline-polyfill/dist/scroll-timeline.js');
}
Ojo que el paquete no declara entry point, así que hay que importar el archivo de dist/.
Pensá también en los tiempos de Baseline: la feature es Newly available desde el 27 de octubre de 2026, y pasa a Widely available 30 meses después, cerca de abril de 2029. Hasta entonces hay una porción de usuarios (iPhones que no actualizan, Firefox ESR) que no la tiene. Por eso la trato como mejora progresiva y no como algo de lo que dependa la página.
Lo que viene
Hay algunas features cerca que resuelven casos que hoy se fuerzan con scroll-driven animations:
- Scroll-triggered animations (
animation-trigger), en Chrome desde la 146 (solo Chrome por ahora). Resuelven el caso "que se dispare una vez al entrar en pantalla y corra por tiempo". El reveal scrubbeado tiene un efecto que no siempre querés: si el usuario vuelve para arriba, el contenido se "des-revela". - Scroll-state container queries (
@container scroll-state(stuck)), en Chrome desde la 133. Para el header sticky o las sombras de scroll son más semánticas, porque son estados y no progreso. Todavía son solo Chrome. - El CSSWG resolvió en agosto empezar a especificar un rango
stuckparaanimation-range, pensado para elementos sticky.
Conclusión
El "funciona en todos lados" de la tabla de Baseline esconde diferencias en rendimiento, en la API de JavaScript y en las partes más nuevas de la spec.
Si tenés código con scroll-driven animations, yo revisaría que el estado base sea visible, que todo esté adentro de @supports y de prefers-reduced-motion, y que se animen propiedades compositables. Con eso y quedándome en el CSS con timelines anónimos, lo uso en producción sin problema. Antes de mergear, eso sí, lo abro en Firefox, que es donde aparecen primero las diferencias.

