Saltar al contenido

CSS, de a poco

Curso de CSS

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:

  1. Scroll del contenedorscroll()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.
  2. Paso por la vistaview()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.

animation-timeline
cada bloque sigue su paso (entry)

Tres días caminando el desierto de Namib

Dunas suaves que se pierden en el horizonte

Dunas de trescientos metros, noches sin una sola luz y un silencio que se escucha.

Salimos antes del amanecer, cuando la arena todavía está fría y las crestas recién empiezan a ponerse naranjas.

La ruta

Día 1
Sesriem → Duna 45 · 9 km
Día 2
Duna 45 → Deadvlei · 14 km
Día 3
Deadvlei → Sossusvlei · 6 km
Arriba no hay nada que hacer más que mirar cómo cambia la sombra.

Al mediodía el viento borra las huellas en minutos. Caminamos por el filo, donde la arena está más firme.

Esa noche armamos la carpa en un valle entre dunas. Sin luna, se veía la Vía Láctea de punta a punta.

Fin del artículo

Seguí leyendo

  • Navegar entre icebergs en Groenlandia
  • Un otoño en los bosques de Columbia Británica
  • Banff sin multitudes: cinco lagos al amanecer
animation-range
articulo.css
@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;
}
}
}
articulo.html
<!-- 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.

css
@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:

  1. Todo el pasocoverDesde que asoma el primer pixel hasta que sale el último.
  2. Entero a la vistacontainMientras el elemento está entero a la vista (o cubre toda la zona visible, si es más alto).
  3. Entrando y saliendoentry · 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.

El paso de un bloque de 40px por un panel de 200px en cinco momentos: asoma, entró, a la vista, toca arriba y salió. cover va de que asoma a que sale; contain, de que entró a que toca arriba; entry, de que asoma a que entró; exit, de que toca arriba a que sale. entry 0% cover 30% mide 72px desde que asoma.

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.

css
.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.

css
.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.

Un artículo sin soporte y con soporte. Sin soporte se leen todos los párrafos y no hay barra; con soporte una barra naranja se llena arriba y el párrafo que está entrando aparece a medias.
css
.progreso { display: none; }
@supports (animation-timeline: scroll()) {
.progreso { display: block; /* y la animación */ }
}
  1. Rendimientotransform · opacitySi animás transform u opacity, el navegador puede correr la animación fuera del hilo principal, y no se traba aunque JavaScript esté ocupado.
  2. Movimiento reducidoprefers-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.

CSSTailwind
animation: reveal linear bothanimate-[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: leftorigin-left
progreso.html
<!-- barra de lectura; @keyframes grow va en el CSS -->
<div
aria-hidden="true"
class="fixed inset-x-0 top-0 h-1 origin-left bg-sky-500 hidden
supports-[animation-timeline:scroll()]:block
supports-[animation-timeline:scroll()]:animate-[grow_linear_both]
supports-[animation-timeline:scroll()]:[animation-timeline:scroll(root)]"
></div>