Scroll-driven animations con Tailwind CSS: animar con el scroll sin JavaScript
Serie «Efectos con Tailwind» · 3 de 4
- Crear un borde animado con Tailwind CSS
- Glassmorphism sin arruinar la legibilidad
- Scroll-driven animations ← estás aquí
- View transitions en Astro
Durante años, animar algo según el scroll significaba usar JavaScript: un IntersectionObserver, un listener de scroll o directamente una librería de 30 KB. Hoy el navegador lo hace solo, y en el compositor en lugar del hilo principal, así que el scroll no se vuelve pesado y no hay código que mantener.
Se llaman scroll-driven animations, y en el fondo son @keyframes normales que avanzan con otro reloj.
Al final cuento un problema que me encontré en este mismo blog: el efecto funcionaba en desarrollo y en producción dejaba de funcionar sin ningún aviso. La culpa era del minificador de CSS, y le puede pasar a cualquiera que use Astro o Vite.
La idea: cambiarle el reloj a la animación
Una animación CSS normal avanza con el tiempo; una scroll-driven animation avanza con el scroll. El único cambio es la propiedad animation-timeline, que tiene dos variantes:
scroll(): el progreso es cuánto se ha desplazado el contenedor, del 0 % arriba al 100 % abajo. Sirve para barras de progreso.view(): el progreso es el recorrido del elemento mientras cruza la pantalla. Sirve para que las cosas aparezcan al entrar.
Con scroll() el reloj es el contenedor, y con view() es el propio elemento. Casi todo lo que quieras hacer depende de elegir bien entre las dos.
Cómo se declara en Tailwind 4
Aquí hay una decisión que conviene explicar, porque es lo que más se rompe.
Los @keyframes van en @theme, dentro de tu global.css, igual que cualquier animación de Tailwind:
@theme {
@keyframes reveal-up {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes grow-x {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
}
Lo natural sería declarar --animate-reveal-up y luego añadir la timeline en una clase arbitraria, así:
<!-- ⚠️ Frágil: no hagas esto -->
<div class="animate-reveal-up [animation-timeline:view()]"></div>
El problema es que el shorthand animation vuelve a poner animation-timeline en auto. Si Tailwind escribe la clase del shorthand después de la de la timeline, la animación se queda sin reloj de scroll y avanza con el tiempo, o no avanza. Y ese orden no lo controlas tú.
Lo seguro es meter las dos cosas en una sola utilidad con @utility, para que el orden deje de importar:
@utility reveal-on-scroll {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 45%;
}
@utility scroll-progress {
animation: grow-x linear;
animation-timeline: scroll(nearest);
transform-origin: left center;
}
Un detalle que ahorra tiempo: @utility no se puede anidar. Si la metes dentro de un @supports, Tailwind detiene el build con `@utility` cannot be nested. Tiene que ser al revés, con el @supports dentro de la utilidad; lo veremos más abajo.
Fíjate también en que no hay animation-duration. Con una timeline de scroll la duración correcta es auto, que significa “ocupa toda la timeline”. Ponerle 2s no tiene sentido.
Barra de progreso de lectura
Es el uso más práctico en un blog. Se hace con scroll() y dos elementos:
<div class="h-64 overflow-y-auto rounded-xl border border-mint-300/40">
<div class="sticky top-0 h-1.5 w-full origin-left bg-mint-400 scroll-progress"></div>
<!-- contenido alto -->
</div>
Haz scroll dentro de la caja y mira la barra verde de arriba:
Sigue bajando…
La barra de arriba no usa JavaScript: su progreso es tu scroll.
Ni IntersectionObserver, ni listeners, ni requestAnimationFrame.
Corre en el compositor, así que no compite con tu JavaScript.
Casi llegas.
100%. Eso es animation-timeline: scroll().
Para aplicarlo a toda la página en vez de a una caja, usa el mismo elemento con fixed top-0 y scroll() sin argumento, y el reloj pasa a ser el scroll del documento.
Aparecer al entrar en pantalla
Aquí entra view(), junto con la propiedad que permite ajustar el efecto: animation-range.
animation-range: entry 0% cover 45% se lee así: empieza cuando el elemento asoma (entry 0%) y termina cuando ha recorrido el 45 % de su paso por la pantalla. Sin ese rango, la animación se reparte por todo el trayecto y el elemento tarda muchísimo en aparecer.
<article class="reveal-on-scroll">Aparezco al entrar</article>
Baja despacio y fíjate en cada tarjeta.
Primera tarjeta
Segunda tarjeta
Tercera tarjeta
Cuarta tarjeta
Ninguna de ellas sabe que existen las otras. No hay orquestación.
No hay retrasos escalonados ni índices. Cada elemento se anima según su propia posición, así que el efecto en cascada sale solo, porque están a distintas alturas.
El problema en producción: el minificador
Esto me costó un rato encontrarlo.
Astro y Vite minifican el CSS con lightningcss por defecto, y lightningcss hace una optimización que aquí lo rompe todo: mete animation-timeline dentro del shorthand animation. Mi utilidad salía del build así:
/* lo que produce el build */
.reveal-on-scroll{animation:linear both reveal-up view()}
Parece razonable, pero el shorthand animation no acepta un valor de timeline, así que el navegador considera la declaración inválida y descarta la regla completa. No hay aviso ni error en la consola: simplemente no pasa nada.
Lo comprobé inyectando las dos formas en Chrome 148 y leyendo qué sobrevivía al parser:
| Forma | Resultado |
|---|---|
animation:linear both reveal-up view() |
La regla se descarta entera |
animation:reveal-up linear both; animation-timeline:view() |
Parsea bien, con animation-duration: auto |
Y en desarrollo funciona, porque ahí no se minifica. Solo se rompe al desplegar.
La solución es cambiar el minificador de CSS en astro.config.mjs:
export default defineConfig({
vite: {
plugins: [tailwindcss()],
build: {
cssMinify: "esbuild",
},
},
});
esbuild no hace esa fusión y la salida queda válida:
.reveal-on-scroll{animation:linear both reveal-up;animation-timeline:view();animation-range:entry cover 45%}
¿Cuánto cuesta? Lo medí en este blog: 123 bytes más de CSS comprimido, un 0,9 %. Me parece un precio muy bajo a cambio de que el efecto funcione.
Accesibilidad
Una animación ligada al scroll puede provocar mareo o desorientación a personas con trastornos vestibulares. Respetar prefers-reduced-motion es lo que pide el criterio 2.3.3 de las WCAG. Es de nivel AAA, así que no es obligatorio en una auditoría AA, pero cuesta muy poco cumplirlo.
Con Tailwind es una variante:
<div class="reveal-on-scroll motion-reduce:animate-none"></div>
O mejor, resuélvelo una sola vez en tu global.css para no tener que acordarte en cada elemento:
@media (prefers-reduced-motion: reduce) {
.reveal-on-scroll,
.scroll-progress {
animation: none;
opacity: 1;
transform: none;
}
}
Ojo con el opacity: 1: si tu keyframe empieza en opacity: 0 y solo quitas la animación, el elemento se queda invisible para siempre. Es un error fácil de cometer, y el resultado es contenido inaccesible justo para quien pidió menos movimiento.
Navegadores sin soporte
En un navegador sin soporte, un elemento que empieza con opacity: 0 nunca aparece. Por eso el estado por defecto tiene que ser el visible, y la animación solo se activa si el navegador la soporta.
Como @utility no se puede anidar, el @supports va dentro:
@utility reveal-on-scroll {
@supports (animation-timeline: view()) {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 45%;
}
}
Que compila a esto:
@supports (animation-timeline:view()){
.reveal-on-scroll{animation:linear both reveal-up;animation-timeline:view();animation-range:entry cover 45%}
}
Puedes consultar el soporte actual en Can I use. La regla es sencilla: si el efecto no carga, el contenido tiene que seguir ahí.
En la última parte de la serie toca animar el paso entre páginas: view transitions en Astro.

