Animaciones con el scroll
Lección 33 de 37 · 4 min
Una animación común avanza con el tiempo. Una animación de scroll avanza con el scroll: si bajás, avanza; si subís, retrocede. Sirve para barras de progreso y para contenido que aparece al entrar, sin una línea de JavaScript.
Del tiempo al scroll
Toda animación sigue una línea de tiempo (animation-timeline). Por defecto es el reloj del documento. Si la cambiás por una línea de scroll, el progreso de la animación pasa a depender de la posición del scroll. Hay dos funciones para crearla:
- Scroll del contenedor
scroll()Sigue el scroll de un contenedor, de 0% arriba de todo a 100% al final.scroll(nearest block), el valor por defecto, usa el contenedor con scroll más cercano y su eje vertical. - Paso por la vista
view()Sigue el paso del propio elemento por la zona visible de ese contenedor, desde que asoma hasta que se va.
Elegí la línea de tiempo y el rango, y bajá dentro del artículo. Las marcas naranjas muestran los valores en vivo: la altura del panel, el avance del scroll y el borde donde empieza y termina cada rango.
@keyframes grow {from { transform: scaleX(0); }to { transform: scaleX(1); }}@keyframes reveal {from { opacity: 0; transform: translateY(12px); }40%, to { opacity: 1; transform: none; }}.articulo {height: 288px;overflow-y: auto;}/* Without support: no bar, and the text stays fully visible */.progreso {display: none;}@supports (animation-timeline: scroll()) {.progreso {display: block;position: sticky;top: 0;height: 4px;transform-origin: left;animation: grow linear both;animation-timeline: scroll(nearest block);}@media (prefers-reduced-motion: no-preference) {.bloque {animation: reveal linear both;animation-timeline: view();animation-range: entry;}}}
<!-- los @keyframes grow y reveal van en el CSS, como en articulo.css --><div class="h-72 overflow-y-auto"><div aria-hidden="true" class="hidden sticky top-0 h-1 origin-left bg-current supports-[animation-timeline:scroll()]:block supports-[animation-timeline:scroll()]:animate-[grow_linear_both] supports-[animation-timeline:scroll()]:[animation-timeline:scroll(nearest_block)]"></div><p class="motion-safe:supports-[animation-timeline:scroll()]:animate-[reveal_linear_both] motion-safe:supports-[animation-timeline:scroll()]:[animation-timeline:view()] motion-safe:supports-[animation-timeline:scroll()]:[animation-range:entry]">Salimos antes del amanecer…</p></div>
Los @keyframes y el shorthand animation son los de siempre. La duración en segundos deja de importar: dejala en auto, el valor por defecto, y la animación ocupa toda la línea de scroll. Escribí animation-timeline después de animation, porque el shorthand la vuelve a su valor inicial.
@keyframes grow {from { transform: scaleX(0); }to { transform: scaleX(1); }}.progreso {transform-origin: left;animation: grow linear both;animation-timeline: scroll();}
Rangos y líneas con nombre
Con view(), el rango (animation-range) elige qué parte del paso del elemento ocupa la animación. Hay cuatro rangos con nombre:
- Todo el paso
coverDesde que asoma el primer pixel hasta que sale el último. - Entero a la vista
containMientras el elemento está entero a la vista (o cubre toda la zona visible, si es más alto). - Entrando y saliendo
entry · exitentry dura mientras entra; exit, mientras sale.
Cada nombre acepta porcentajes, y el inicio y el fin pueden ser de rangos distintos. En un panel de 200px, un elemento de 40px recorre 240px de cover, así que entry 0% cover 30% termina 72px después de que asoma: ya entró entero y subió 32px más.
Ojo con el final: contain termina cuando el elemento toca el borde de arriba y cover cuando sale, así que los últimos necesitan contenido debajo para llegar al 100%. Por eso el artículo de la demo cierra con más lecturas.
.parrafo {animation: reveal linear both;animation-timeline: view();animation-range: entry 0% cover 30%;}
Para animar un elemento con el scroll de otro, nombrá la línea de tiempo con scroll-timeline-name o view-timeline-name. Los nombres empiezan con dos guiones y solo los ven el elemento que la nombra y sus descendientes; si el elemento animado está en otra rama, declará timeline-scope en un ancestro común.
.pagina { timeline-scope: --tarjeta; }.tarjeta { view-timeline-name: --tarjeta; }.indicador {animation: grow linear both;animation-timeline: --tarjeta;}
Mejora progresiva
Envolvé todo en @supports, y pensá primero cómo se ve sin soporte: el texto tiene que quedar visible y la barra, oculta. Así nadie se queda con contenido transparente.
.progreso { display: none; }@supports (animation-timeline: scroll()) {.progreso { display: block; /* y la animación */ }}
- Rendimiento
transform · opacitySi animástransformuopacity, el navegador puede correr la animación fuera del hilo principal, y no se traba aunque JavaScript esté ocupado. - Movimiento reducido
prefers-reduced-motionPoné los reveals dentro de@media (prefers-reduced-motion: no-preference). Una barra de progreso puede quedarse, porque no mueve el contenido.
Una función más nueva, de la misma familia: las container queries de estado de scroll. Con container-type: scroll-state y @container scroll-state(stuck: top) estilás los hijos de un elemento sticky cuando queda pegado. Por ahora solo está en navegadores basados en Chromium (desde la 133), así que también va como mejora.
En Tailwind
Tailwind v4 no trae utilidades para animation-timeline ni animation-range: van como propiedades arbitrarias. Los @keyframes quedan en el CSS, y @supports y el movimiento reducido son variantes.
| CSS | Tailwind |
|---|---|
| animation: reveal linear both | animate-[reveal_linear_both] |
| animation-timeline: view() | [animation-timeline:view()] |
| animation-range: entry 0% cover 30% | [animation-range:entry_0%_cover_30%] |
| @supports (animation-timeline: scroll()) | supports-[animation-timeline:scroll()]: |
| @media (prefers-reduced-motion: no-preference) | motion-safe: |
| transform-origin: left | origin-left |
<!-- barra de lectura; @keyframes grow va en el CSS --><divaria-hidden="true"class="fixed inset-x-0 top-0 h-1 origin-left bg-sky-500 hiddensupports-[animation-timeline:scroll()]:blocksupports-[animation-timeline:scroll()]:animate-[grow_linear_both]supports-[animation-timeline:scroll()]:[animation-timeline:scroll(root)]"></div>
Scroll-driven animations
Lesson 33 of 37 · 4 min
A regular animation moves forward with time. A scroll-driven animation moves forward with scroll: scroll down and it advances; scroll up and it goes back. It powers progress bars and content that appears as it enters, without a line of JavaScript.
From time to scroll
Every animation follows a timeline (animation-timeline). By default it's the document's clock. Swap it for a scroll timeline and the animation's progress depends on the scroll position instead. Two functions create one:
- The container's scroll
scroll()Follows a container's scroll, from 0% at the very top to 100% at the end.scroll(nearest block), the default, uses the nearest scroll container and its vertical axis. - A pass through the view
view()Follows the element's own pass through that container's visible area, from the moment it peeks in until it leaves.
Pick the timeline and the range, then scroll inside the article. The orange marks show the live values: the panel's height, the scroll progress and the edge where each range starts and ends.
@keyframes grow {from { transform: scaleX(0); }to { transform: scaleX(1); }}@keyframes reveal {from { opacity: 0; transform: translateY(12px); }40%, to { opacity: 1; transform: none; }}.article {height: 288px;overflow-y: auto;}/* Without support: no bar, and the text stays fully visible */.progress {display: none;}@supports (animation-timeline: scroll()) {.progress {display: block;position: sticky;top: 0;height: 4px;transform-origin: left;animation: grow linear both;animation-timeline: scroll(nearest block);}@media (prefers-reduced-motion: no-preference) {.block {animation: reveal linear both;animation-timeline: view();animation-range: entry;}}}
<!-- the grow and reveal @keyframes live in your CSS, as in article.css --><div class="h-72 overflow-y-auto"><div aria-hidden="true" class="hidden sticky top-0 h-1 origin-left bg-current supports-[animation-timeline:scroll()]:block supports-[animation-timeline:scroll()]:animate-[grow_linear_both] supports-[animation-timeline:scroll()]:[animation-timeline:scroll(nearest_block)]"></div><p class="motion-safe:supports-[animation-timeline:scroll()]:animate-[reveal_linear_both] motion-safe:supports-[animation-timeline:scroll()]:[animation-timeline:view()] motion-safe:supports-[animation-timeline:scroll()]:[animation-range:entry]">We left before dawn…</p></div>
The @keyframes and the animation shorthand are the usual ones. The duration in seconds stops mattering: leave it at auto, the default, and the animation fills the whole scroll timeline. Write animation-timeline after animation, because the shorthand resets it to its initial value.
@keyframes grow {from { transform: scaleX(0); }to { transform: scaleX(1); }}.progress {transform-origin: left;animation: grow linear both;animation-timeline: scroll();}
Ranges and named timelines
With view(), the range (animation-range) picks which part of the element's pass the animation takes up. There are four named ranges:
- The whole pass
coverFrom the first pixel peeking in until the last one leaves. - Fully in view
containWhile the element is fully in view (or covers the whole visible area, if it's taller). - Coming in and going out
entry · exitentry lasts while it comes in; exit, while it goes out.
Each name takes percentages, and the start and end can come from different ranges. In a 200px panel, a 40px element travels 240px of cover, so entry 0% cover 30% ends 72px after it peeks in: it has fully entered and moved 32px further up.
Mind the end: contain finishes when the element reaches the top edge and cover when it leaves, so the last ones need content below them to reach 100%. That's why the demo's article ends with more stories.
.paragraph {animation: reveal linear both;animation-timeline: view();animation-range: entry 0% cover 30%;}
To animate one element with another one's scroll, name the timeline with scroll-timeline-name or view-timeline-name. Names start with two dashes and are only visible to the element that names the timeline and its descendants; if the animated element lives in another branch, declare timeline-scope on a common ancestor.
.page { timeline-scope: --card; }.card { view-timeline-name: --card; }.indicator {animation: grow linear both;animation-timeline: --card;}
Progressive enhancement
Wrap it all in @supports, and design the unsupported version first: the text has to stay visible and the bar hidden. That way nobody is left with transparent content.
.progress { display: none; }@supports (animation-timeline: scroll()) {.progress { display: block; /* and the animation */ }}
- Performance
transform · opacityAnimatetransformoropacityand the browser can run the animation off the main thread, so it doesn't stutter while JavaScript is busy. - Reduced motion
prefers-reduced-motionPut the reveals inside@media (prefers-reduced-motion: no-preference). A progress bar can stay, because it doesn't move the content.
A newer feature from the same family: scroll-state container queries. With container-type: scroll-state and @container scroll-state(stuck: top) you style the children of a sticky element while it's stuck. For now it's only in Chromium-based browsers (since 133), so it's an enhancement too.
In Tailwind
Tailwind v4 has no utilities for animation-timeline or animation-range: they go in arbitrary properties. The @keyframes stay in your CSS, and @supports and reduced motion are variants.
| CSS | Tailwind |
|---|---|
| animation: reveal linear both | animate-[reveal_linear_both] |
| animation-timeline: view() | [animation-timeline:view()] |
| animation-range: entry 0% cover 30% | [animation-range:entry_0%_cover_30%] |
| @supports (animation-timeline: scroll()) | supports-[animation-timeline:scroll()]: |
| @media (prefers-reduced-motion: no-preference) | motion-safe: |
| transform-origin: left | origin-left |
<!-- reading bar; @keyframes grow lives in your CSS --><divaria-hidden="true"class="fixed inset-x-0 top-0 h-1 origin-left bg-sky-500 hiddensupports-[animation-timeline:scroll()]:blocksupports-[animation-timeline:scroll()]:animate-[grow_linear_both]supports-[animation-timeline:scroll()]:[animation-timeline:scroll(root)]"></div>
