Animaciones ligadas al scroll con CSS puro: guía de scroll-driven animations en 2026
El scroll dejó de ser un evento que hay que escuchar y pasó a ser la línea de tiempo de la animación. Con CSS puro se puede construir la barra de progreso de lectura, el revelado de bloques y un parallax suave, sin una sola línea de JavaScript.
Qué son las animaciones ligadas al scroll (y qué no)
Una animación CSS normal avanza con el reloj: empieza, dura lo que dura y termina. Una animación ligada al scroll avanza con la posición de la barra de desplazamiento. El cambio parece menor y lo cambia todo: desaparece el listener que calculaba porcentajes en cada frame y desaparece la librería que hacía ese trabajo.
La duración deja de ser tiempo: el progreso lo marca el scroll
Cuando una animación tiene duración automática y una línea de tiempo de scroll, el 0% del recorrido es el inicio del desplazamiento y el 100% es el final. No hay milisegundos que ajustar: el usuario decide el ritmo con el dedo o con la rueda.
Dos formas de recorrer: scroll() y view()
scroll() sigue el progreso de un contenedor con scroll, incluida la página entera con scroll(root). view() sigue el recorrido de un elemento concreto al cruzar el área visible. La primera sirve para efectos atados a la página; la segunda, para revelados.
Lee también
Qué reemplaza de verdad: IntersectionObserver y las librerías de scroll
La alternativa clásica al revelado era IntersectionObserver: observar si un bloque entraba en pantalla y añadir o quitar una clase. Funciona, pero vive en JavaScript y añade trabajo al hilo principal. Las librerías de animación resuelven más casos a cambio de peso y de una capa de abstracción. Las animaciones ligadas al scroll cubren buena parte de ambos casos con hojas de estilo.
Soporte real en septiembre de 2026
Chromium 115+ y Safari 26: funcionan sin banderas
Chromium (Chrome, Edge, Opera, Brave) soporta animation-timeline desde la versión 115, publicada en julio de 2023. Safari lo incorporó en su versión 26. En los datos de caniuse, el soporte global de animation-timeline ronda el 82-83%.
Firefox sigue fuera (bandera en about:config) y por eso no es Baseline
Firefox todavía no las trae activadas por defecto. Se pueden probar activando layout.css.scroll-driven-animations.enabled en about:config, y su implementación es parcial. Por eso conviene no decir que son Baseline: técnicamente no lo son.
Mejora progresiva: @supports con animation-timeline y el detalle de animation-range
Que no sea Baseline no significa que no se puedan usar. Significa que se envían como mejora progresiva, dentro de un bloque @supports, y que si el navegador no las entiende, el bloque se ignora y todo sigue funcionando. El detalle que muchos pasan por alto: conviene comprobar también animation-range, porque hay motores que aceptan la timeline y no el rango.
@supports (animation-timeline: view()) and (animation-range: entry) {
.reveal {
animation: fade auto linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}La base: animation-timeline y animation-range
Dos propiedades hacen el trabajo. animation-timeline dice de dónde sale el progreso; animation-range dice en qué tramo de ese progreso ocurre la animación.
Declarar la animación con animation-duration: auto y fill-mode both
La duración tiene que ser auto: así la animación no se mide en segundos, sino en el recorrido de la línea de tiempo. El fill-mode: both mantiene el estado inicial antes del rango y el final después.
@keyframes fade {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal {
animation: fade auto linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}Los rangos con nombre: cover, contain, entry y exit
Para las líneas de tiempo de vista hay cuatro nombres. entry es el momento en que el elemento entra en el área visible. contain es cuando está completamente dentro. exit, cuando empieza a salir. cover abarca todo el recorrido y es el valor por defecto.
Porcentajes y rangos cruzados: entry 25% cover 50%
Los rangos se pueden combinar y ajustar con porcentajes. entry 25% cover 50% significa que la animación empieza cuando el elemento ya ha entrado un 25% y termina a mitad del recorrido completo. Es la forma de controlar con precisión cuándo se ve el efecto.
Timelines con nombre: scroll-timeline-name, view-timeline-name y timeline-scope
También se puede dar nombre a una línea de tiempo y enlazar elementos que no son parientes directos. Se declara con scroll-timeline-name o view-timeline-name en el elemento que la produce, se consume con animation-timeline: --nombre en el que se anima, y timeline-scope amplía el alcance del nombre cuando los elementos están en ramas distintas del árbol.
.galeria {
scroll-timeline-name: --carrusel;
scroll-timeline-axis: inline;
overflow-x: auto;
}
.tarjeta {
animation: fade auto linear both;
animation-timeline: --carrusel;
}Patrón 1: barra de progreso de lectura
Es el ejemplo más rentable y el más corto de todos.
Del scroll de la raíz al ancho de la barra
La barra es un elemento fijo arriba y su escala horizontal sigue el progreso de la página. Con scroll(root) no hace falta un contenedor especial.
Animar transform: scaleX en lugar de width (y por qué)
La barra crece con transform: scaleX(), no con width. La razón es el rendimiento: transform lo resuelve el compositor sin recalcular el diseño de la página; width obliga al navegador a rehacer el layout en cada frame.
.progreso {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
transform-origin: 0 50%;
background: #38bdf8;
animation: crecer auto linear both;
animation-timeline: scroll(root);
}
@keyframes crecer {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}Dejarla visible y discreta cuando el navegador no la anima
Si el navegador no soporta timelines, la barra se quedaría con el estado final del @keyframes o directamente invisible. Conviene darle un estado base razonable y envolver el efecto en @supports, para que en Firefox no aparezca una barra congelada a media pantalla.
Patrón 2: revelar bloques al entrar en pantalla
view() con animation-range: entry 0% entry 40%
Este es el sustituto directo de IntersectionObserver. El bloque aparece cuando entra en pantalla y se completa cuando ha recorrido el primer 40% de su rango de entrada.
.reveal {
animation: fade auto linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}El contenido nunca debe desaparecer si no hay soporte
Aquí está el error más caro: dejar el bloque con opacity: 0 en el CSS base. Si el navegador no soporta timelines, el contenido nunca aparece. La animación tiene que estar solo dentro del @supports, con el estado visible como base.
Escalonar varias tarjetas sin JavaScript
Para que varias tarjetas no entren todas a la vez, basta con dar rangos distintos a cada una con animation-range. Cada elemento tiene su propia línea de tiempo de vista, así que el escalonado sale gratis y sin contar índices en JavaScript. Cuanto más tarde empiece el rango, más tarde aparece la tarjeta.
Patrón 3: parallax y cabecera que se encoge
Capas a distinta velocidad con scroll()
El parallax se hace con dos capas que comparten la línea de tiempo del scroll de la raíz y recorren distancias distintas. Cuanto mayor el desplazamiento, más rápido parece moverse la capa.
.capa-lejana {
animation: subir-lento auto linear both;
animation-timeline: scroll(root);
}
.capa-cercana {
animation: subir-rapido auto linear both;
animation-timeline: scroll(root);
}
@keyframes subir-lento { to { transform: translateY(-40px); } }
@keyframes subir-rapido { to { transform: translateY(-140px); } }Cabecera pegajosa que cambia de tamaño con view()
Una cabecera fija puede encogerse en los primeros píxeles de scroll usando un rango acotado, para que el efecto termine pronto y no dependa de toda la página.
.cabecera {
position: sticky;
top: 0;
animation: compactar auto linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
@keyframes compactar {
from { padding-block: 1.25rem; }
to { padding-block: 0.5rem; }
}Ojo con este último: animar padding-block cambia el tamaño del elemento y provoca recálculo de diseño. Si buscas el máximo rendimiento, anima un transform: scale() en el contenido interior de la cabecera y deja el alto fijo.
Rendimiento: cuándo esto es rápido y cuándo no
Animaciones que quedan en el compositor frente a las que provocan recálculo
Si la animación toca transform u opacity, el navegador la resuelve en el hilo del compositor y el hilo principal queda libre. Si toca propiedades que afectan al diseño o al pintado, como width, height, padding o top, el trabajo vuelve al hilo principal y ahí aparecen los tirones.
Qué se nota en métricas como INP
Interaction to Next Paint mide cuánto tarda la página en responder tras una interacción. Hay reportes de migraciones que muestran mejoras notables de INP al pasar de listeners de scroll a timelines declarativas, pero son mediciones de terceros en sitios concretos, no una garantía para cualquier proyecto.
Accesibilidad y movimiento reducido
prefers-reduced-motion aplicado a timelines de scroll
Hay usuarios que configuran su sistema para reducir el movimiento, y las animaciones ligadas al scroll son justamente las que más molestan. Se neutralizan con una media query que quite la animación y deje el estado final.
@media (prefers-reduced-motion: reduce) {
.reveal,
.progreso,
.capa-cercana {
animation: none;
}
}Cuándo conviene una animación por tiempo en vez de por scroll
No todo es scroll. Una animación ligada al scroll es ideal para arrastrar el progreso con el desplazamiento, pero si el efecto debe reproducirse una sola vez con su propio tiempo al entrar en pantalla, el comportamiento de arrastre se siente raro y encaja mejor una animación por tiempo.
Errores frecuentes que hacen que "no funcione"
Sin contenedor con scroll no hay timeline
Si el contenedor al que apunta la línea de tiempo no tiene desbordamiento ni desplazamiento posible, no hay progreso que seguir. Un overflow: hidden donde esperabas un scroller es la causa habitual.
Olvidar el fallback por tiempo y dejar el contenido invisible
Ya lo dijimos, pero es el error que más se repite: estado visible por defecto y animación dentro del @supports. Si no, en los navegadores sin soporte el contenido queda oculto para siempre.
Elementos que no llegan a cruzar el scrollport
Si un elemento está siempre visible o nunca entra en el área, su rango de vista no avanza y la animación no se ve. Suele pasar con bloques muy altos o con elementos que ya están dentro al cargar la página.
Un ejemplo completo: del progreso a la tarjeta que aparece
Junta las piezas anteriores: una barra con scroll(root), un revelado con view() y rangos distintos por tarjeta, y todo envuelto en @supports con el estado final como base. Son unas veinte líneas de CSS y ningún archivo de JavaScript.
@supports (animation-timeline: view()) and (animation-range: entry) {
.progreso { animation-timeline: scroll(root); }
.reveal { animation-timeline: view(); animation-range: entry 0% entry 40%; }
}Conclusión
Las animaciones ligadas al scroll no reemplazan toda librería de animación, pero sí cubren los tres efectos que más se piden y lo hacen sin sumar dependencias. Con @supports se pueden enviar hoy sin riesgo, y cuando Firefox active su implementación, el mismo CSS funcionará sin cambios. Si te interesa el resto del CSS moderno que ya se puede usar en producción, tengo la guía de la View Transitions API sin librerías, la de animaciones CSS ligeras y efectivas y el clásico del scroll suave para anclas con JavaScript.


