Animaciones CSS controladas por scroll: más allá de JavaScript
Crea animaciones ligadas al scroll con la API de CSS: timelines de progreso y de vista, parallax y encabezados sticky, fuera del hilo principal.

Tradicionalmente, las animaciones controladas por scroll han requerido JavaScript: IntersectionObserver, listeners de eventos de scroll o bibliotecas de animación como ScrollTrigger de GSAP. Funcionan, pero se ejecutan en el hilo principal, compitiendo por tiempo de CPU con la lógica de tu aplicación. Cada evento de scroll que dispara un callback de JavaScript es un riesgo de jank.
Las animaciones CSS controladas por scroll sacan este trabajo del hilo principal por completo. El compositor del navegador se encarga de las animaciones, produciendo efectos de scroll fluidos a 60 fps sin una sola línea de JavaScript. La API usa dos tipos de líneas de tiempo: scroll() para el progreso del scroll del documento y view() para la visibilidad de un elemento dentro del viewport.
Línea de tiempo de progreso de scroll
Una línea de tiempo de progreso de scroll mapea la posición de scroll de un contenedor al progreso de la animación. A medida que el usuario se desplaza de arriba hacia abajo, la animación se reproduce del 0% al 100%.
/* ❌ JavaScript scroll handler — runs on main thread */
/*
window.addEventListener('scroll', () => {
const progress = window.scrollY /
(document.body.scrollHeight - window.innerHeight);
progressBar.style.width = `${progress * 100}%`;
});
// Fires 60+ times per second, blocks main thread
*//* ✅ CSS scroll progress — off main thread */
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(
to right,
#3b82f6,
#8b5cf6
);
transform-origin: left;
width: 100%;
/* Define the animation */
animation: scaleProgress linear;
/* Link to scroll position */
animation-timeline: scroll();
}
@keyframes scaleProgress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}La función scroll() crea una línea de tiempo vinculada al ancestro desplazable más cercano. Por defecto, sigue el eje de scroll en bloque (vertical). La animación se reproduce en sincronía con el scroll: sin JavaScript, sin jank.
View timeline: animaciones de visibilidad de elementos
La línea de tiempo view() dispara animaciones según cuándo un elemento entra y sale del viewport. Esto reemplaza a IntersectionObserver como disparador de animaciones.
/* ❌ IntersectionObserver approach */
/*
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
},
{ threshold: 0.2 }
);
document.querySelectorAll('.fade-in')
.forEach((el) => observer.observe(el));
*//* ✅ CSS view timeline — declarative, off-thread */
.fade-in {
animation: fadeSlideIn linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Staggered entrance for card grids */
.card {
animation: cardEntrance ease-out both;
animation-timeline: view();
animation-range: entry 10% entry 80%;
}
.card:nth-child(2) {
animation-delay: 0.1s;
}
.card:nth-child(3) {
animation-delay: 0.2s;
}
@keyframes cardEntrance {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}Animation range: ajuste fino de los disparadores
La propiedad animation-range controla exactamente en qué momento de la línea de tiempo se reproduce la animación. Es la clave para efectos precisos controlados por scroll.
/* Full range options:
entry — element entering the viewport
exit — element leaving the viewport
contain — element fully contained in viewport
cover — from entry start to exit end
*/
/* Play animation only while element enters */
.enter-only {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
/* Play animation while element is fully visible */
.while-visible {
animation: pulse linear both;
animation-timeline: view();
animation-range: contain 0% contain 100%;
}
/* Play across the entire visibility lifecycle */
.full-lifecycle {
animation: fullCycle linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
@keyframes reveal {
from {
opacity: 0;
clip-path: inset(0 0 100% 0);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}
@keyframes fullCycle {
0% {
opacity: 0;
transform: translateX(-50px);
}
30% {
opacity: 1;
transform: translateX(0);
}
70% {
opacity: 1;
transform: translateX(0);
}
100% {
opacity: 0;
transform: translateX(50px);
}
}Efectos parallax sin JavaScript
El scroll parallax tradicionalmente requiere calcular posiciones de desplazamiento en JavaScript. Las líneas de tiempo de scroll de CSS lo hacen declarativo.
.parallax-container {
position: relative;
overflow: hidden;
height: 100vh;
}
.parallax-bg {
position: absolute;
inset: -20% 0;
background: url("/hero-bg.webp") center/cover;
animation: parallaxShift linear;
animation-timeline: scroll(root);
}
@keyframes parallaxShift {
from {
transform: translateY(-10%);
}
to {
transform: translateY(10%);
}
}
/* Multi-layer parallax */
.layer-back {
animation: parallaxSlow linear;
animation-timeline: scroll(root);
}
.layer-mid {
animation: parallaxMedium linear;
animation-timeline: scroll(root);
}
.layer-front {
animation: parallaxFast linear;
animation-timeline: scroll(root);
}
@keyframes parallaxSlow {
from { transform: translateY(0); }
to { transform: translateY(-50px); }
}
@keyframes parallaxMedium {
from { transform: translateY(0); }
to { transform: translateY(-100px); }
}
@keyframes parallaxFast {
from { transform: translateY(0); }
to { transform: translateY(-200px); }
}Transformaciones de encabezados sticky
Encabezados que se encogen, cambian de fondo o revelan una sombra a medida que haces scroll, todo sin JavaScript.
.header {
position: sticky;
top: 0;
z-index: 100;
/* Named scroll timeline on the root */
animation: headerTransform linear;
animation-timeline: scroll(root);
animation-range: 0px 200px;
}
@keyframes headerTransform {
from {
padding-block: 1.5rem;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
to {
padding-block: 0.5rem;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(12px);
}
}
.header .logo {
animation: logoShrink linear;
animation-timeline: scroll(root);
animation-range: 0px 200px;
}
@keyframes logoShrink {
from {
height: 48px;
}
to {
height: 32px;
}
}Mejora progresiva
Todavía no todos los navegadores soportan las animaciones controladas por scroll. Usa @supports para ofrecer la experiencia mejorada donde esté disponible y un fallback funcional en los demás casos.
/* Base styles — works everywhere */
.fade-in {
opacity: 1;
}
/* Enhanced experience where supported */
@supports (animation-timeline: view()) {
.fade-in {
opacity: 0;
animation: fadeSlideIn linear both;
animation-timeline: view();
animation-range: entry 10% entry 90%;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
opacity: 1;
transform: none;
}
.progress-bar {
animation: none;
transform: scaleX(1);
}
.parallax-bg {
animation: none;
transform: none;
}
}Conclusiones clave
Las animaciones CSS controladas por scroll usan animation-timeline: scroll() y animation-timeline: view() para vincular animaciones a la posición de scroll y a la visibilidad de los elementos respectivamente, ejecutándose por completo en el hilo del compositor del navegador sin bloquear la ejecución de JavaScript ni causar jank en el scroll. La propiedad animation-range controla exactamente cuándo se reproducen las animaciones dentro de su línea de tiempo mediante rangos con nombre como entry, exit, contain y cover con desplazamientos porcentuales, lo que da un control preciso sobre los puntos de disparo que antes requerían cálculos complejos de umbrales con IntersectionObserver. Los efectos parallax, las transformaciones de encabezados sticky y los indicadores de progreso se convierten en CSS puramente declarativo: los mismos efectos que antes requerían listeners de eventos de scroll y bucles de requestAnimationFrame ahora no necesitan nada de JavaScript y rinden mejor porque evitan el hilo principal por completo. Usa @supports (animation-timeline: view()) para la mejora progresiva e incluye siempre @media (prefers-reduced-motion: reduce) para desactivar las animaciones de scroll en los usuarios que han indicado sensibilidad al movimiento en la configuración de su sistema.


