<!-- Canonical: https://lucioduran.com/es/blog/web-animations-api-scroll-driven-animations
Language: es
Content-SHA256: e12b1bba30ae5e02fa1e0381ec0c96026d4d5d7c6511c0d5caff90e1ee23eb30
-->

Frontend

# Scroll-Driven Animations con CSS scroll-timeline, ViewTimeline y la Web Animations API

2025-12-047 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/web-animations-api-scroll-driven-animations>)

## Dos Tipos de Scroll Timelines

Las CSS scroll-driven animations introducen dos tipos de timeline que reemplazan dos patrones diferentes de JavaScript:

### ScrollTimeline: Progreso a Través de un Scroll Container

Un ` ScrollTimeline ` trackea cuánto se scrolleó un container. Reemplaza los patrones clásicos de JavaScript de "barra de progreso de lectura" y "efecto parallax":

```css
/* Barra de progreso de lectura, cero JavaScript */
.progress-bar {
 position: fixed;
 top: 0;
 left: 0;
 height: 3px;
 background: #d97757;
 transform-origin: left;
 animation: grow-width linear both;
 animation-timeline: scroll(root);
}

@keyframes grow-width {
 from { transform: scaleX(0); }
 to { transform: scaleX(1); }
}
```

Eso es todo. Nada de ` window.addEventListener('scroll', ...) `, nada de ` requestAnimationFrame `, nada de throttling, nada de ` document.documentElement.scrollTop / document.documentElement.scrollHeight `. El browser maneja todo en el compositor thread.

La función ` scroll() ` acepta dos argumentos:

```css
scroll(<scroller> <axis>)

/* Ejemplos */
animation-timeline: scroll(); /* ancestro scroll más cercano, eje block */
animation-timeline: scroll(root); /* scroller root (generalmente document) */
animation-timeline: scroll(root block); /* scroller root, eje vertical */
animation-timeline: scroll(self inline);/* el elemento mismo, eje horizontal */
```

### ViewTimeline: Visibilidad del Elemento en el Viewport

Un ` ViewTimeline ` trackea la posición de un elemento mientras cruza el viewport (o cualquier scroll container). Reemplaza Intersection Observer para animaciones:

```css
/* Fade-in-up cuando el elemento entra al viewport */
.card {
 animation: fade-in-up linear both;
 animation-timeline: view();
 animation-range: entry 10% entry 90%;
}

@keyframes fade-in-up {
 from {
 opacity: 0;
 transform: translateY(40px);
 }
 to {
 opacity: 1;
 transform: translateY(0);
 }
}
```

## Entendiendo animation-range

Esta es la parte más poderosa, y más confusa, de las scroll-driven animations. La propiedad ` animation-range ` define qué porción del scroll timeline ocupa la animación.

Para ViewTimeline, hay rangos nombrados:

```
 VIEWPORT
 ┌─────────────┐
 │ │
 cover 0% ──→ │ ┌─────────┐ │ ← entry 100% / contain 0%
 │ │ ELEMENT │ │
 cover 100%──→ │ └─────────┘ │ ← exit 0% / contain 100%
 │ │
 └─────────────┘

 entry: desde el borde delantero del elemento entrando hasta completamente dentro
 exit: desde completamente dentro hasta el borde trasero saliendo
 contain: mientras el elemento está completamente contenido en el viewport
 cover: desde primer pixel visible hasta último pixel saliendo
```

Ejemplos reales:

```css
/* Animar solo mientras entra al viewport */
animation-range: entry;

/* Animar a través de todo el recorrido de visibilidad */
animation-range: cover;

/* Empezar cuando 25% entró, terminar cuando 75% entró */
animation-range: entry 25% entry 75%;

/* Empezar cuando el elemento entra, terminar cuando empieza a salir */
animation-range: entry exit;
```

## Construyendo una Sección Parallax Real

A continuación, un efecto parallax de producción. La imagen de fondo se mueve a un ritmo diferente que el contenido, con un overlay de fade:

```css
.parallax-section {
 position: relative;
 overflow: hidden;
 min-height: 100vh;
}

.parallax-section__background {
 position: absolute;
 inset: -20% 0;
 background-image: url('/hero.webp');
 background-size: cover;

 animation: parallax-shift linear both;
 animation-timeline: view();
 animation-range: cover;
}

@keyframes parallax-shift {
 from { transform: translateY(-10%); }
 to { transform: translateY(10%); }
}

.parallax-section__overlay {
 position: absolute;
 inset: 0;
 background: linear-gradient(to bottom, transparent 30%, rgba(0,0,0,0.7));

 animation: overlay-fade linear both;
 animation-timeline: view();
 animation-range: entry;
}

@keyframes overlay-fade {
 from { opacity: 0; }
 to { opacity: 1; }
}
```

Antes, esto requería como mínimo:

```javascript
// La vieja forma, no hagas esto más
const section = document.querySelector('.parallax-section');
const bg = section.querySelector('.parallax-section__background');

function handleScroll() {
 const rect = section.getBoundingClientRect();
 const progress = -rect.top / (rect.height + window.innerHeight);
 const yOffset = (progress - 0.5) * 20;
 bg.style.transform = `translateY(${yOffset}%)`;
}

window.addEventListener('scroll', handleScroll, { passive: true });
// Más cleanup, ResizeObserver para recalcular, throttling con RAF...
```

## La Web Animations API: Cuando CSS No Alcanza

Para animaciones de scroll dinámicas donde los parámetros no se conocen al momento de escribir el CSS, la Web Animations API provee control de JavaScript con performance del compositor thread:

```javascript
const card = document.querySelector('.product-card');

card.animate(
 [
 { opacity: 0, transform: 'translateY(60px) scale(0.95)' },
 { opacity: 1, transform: 'translateY(0) scale(1)' }
 ],
 {
 timeline: new ViewTimeline({ subject: card }),
 rangeStart: 'entry 10%',
 rangeEnd: 'entry 80%',
 fill: 'both',
 easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
 }
);
```

Es particularmente útil para animaciones escalonadas donde cada elemento necesita una configuración ligeramente diferente:

```javascript
const cards = document.querySelectorAll('.grid-card');

cards.forEach((card, index) => {
 const entryStart = Math.max(0, 5 - index * 2);
 const entryEnd = Math.min(100, 70 + index * 5);

 card.animate(
 [
 { opacity: 0, transform: `translateY(${30 + index * 10}px)` },
 { opacity: 1, transform: 'translateY(0)' }
 ],
 {
 timeline: new ViewTimeline({ subject: card }),
 rangeStart: `entry ${entryStart}%`,
 rangeEnd: `entry ${entryEnd}%`,
 fill: 'both',
 }
 );
});
```

## Named Scroll Timelines: Referencias Cross-Element

A veces se necesita animar un elemento basándote en el scroll timeline de otro elemento. Los timelines nombrados manejan esto:

```css
.horizontal-gallery {
 overflow-x: auto;
 scroll-timeline-name: --gallery;
 scroll-timeline-axis: inline;
 display: flex;
 gap: 1rem;
}

.gallery-progress {
 height: 4px;
 background: #d97757;
 transform-origin: left;
 animation: scale-x linear both;
 animation-timeline: --gallery;
}

@keyframes scale-x {
 from { transform: scaleX(0); }
 to { transform: scaleX(1); }
}
```

## Progressive Enhancement

No todos los browsers soportaron scroll-driven animations al mismo tiempo. Este es un patrón de progressive enhancement recomendado:

```css
/* Base: sin animación, contenido siempre visible */
.reveal-element {
 opacity: 1;
 transform: translateY(0);
}

/* Mejorado: solo aplicar animación si está soportado */
@supports (animation-timeline: view()) {
 .reveal-element {
 animation: reveal linear both;
 animation-timeline: view();
 animation-range: entry 10% entry 70%;
 }

 @keyframes reveal {
 from {
 opacity: 0;
 transform: translateY(30px);
 }
 to {
 opacity: 1;
 transform: translateY(0);
 }
 }
}
```

El contenido siempre es visible y accesible. Los browsers que soportan scroll-driven animations obtienen la experiencia mejorada. No se necesita polyfill para el caso base.

## Performance: Mediciones Reales

Estas mediciones provienen de un Pixel 6a (Android gama media) usando Chrome DevTrace:

```
Scroll handler (JS, throttled con rAF):
 - Tiempo main thread por frame: 2.8ms avg, 18ms p99
 - Frames perdidos del compositor: 3-7 por gesto de scroll
 - Bundle JS total para efectos scroll: ~12KB gzip

GSAP ScrollTrigger:
 - Tiempo main thread por frame: 4.2ms avg, 22ms p99
 - Frames perdidos del compositor: 5-12 por gesto de scroll
 - Bundle JS total: ~28KB gzip (GSAP core + ScrollTrigger)

CSS scroll-driven animations:
 - Tiempo main thread por frame: 0ms (corre en compositor)
 - Frames perdidos del compositor: 0
 - Bundle JS para efectos scroll: 0KB
```

La diferencia es especialmente visible en páginas con mucho trabajo del main thread, llamadas API, parsing JSON, rendering de React. El approach CSS no compite con ese trabajo porque corre en un thread completamente separado.

## Patrones Comunes que Vale la Pena Adoptar

### Shadow en header sticky

```css
.site-header {
 position: sticky;
 top: 0;
 animation: header-shadow linear both;
 animation-timeline: scroll(root);
 animation-range: 0px 100px;
}

@keyframes header-shadow {
 from { box-shadow: 0 0 0 transparent; }
 to { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
}
```

### Transición de tema de color por scroll

```css
:root {
 animation: theme-shift linear both;
 animation-timeline: scroll(root);
}

@keyframes theme-shift {
 0% { --bg: #1a1816; --text: #f5f0eb; }
 50% { --bg: #2a2520; --text: #f5f0eb; }
 100% { --bg: #faf9f5; --text: #1a1816; }
}

body {
 background: var(--bg);
 color: var(--text);
 transition: background 0.3s, color 0.3s;
}
```

Las scroll-driven animations son una de esas raras adiciones a la plataforma web donde la nueva forma es mejor en todas las dimensiones: menos código, mejor performance, modelo mental más simple, cero dependencias. Si todavía se está usando GSAP ScrollTrigger o Intersection Observer para animaciones de entrada, es hora de borrar algo de JavaScript. Las ganancias de rendimiento en dispositivos móviles son sustanciales, y la eliminación de dependencias JavaScript reduce significativamente el bundle size.

## Preguntas frecuentes

### ¿Qué navegadores soportan scroll-driven animations?

A principios de 2026, Chrome 115+, Edge 115+, y Firefox 128+ soportan CSS scroll-driven animations. Safari agregó soporte en Safari 18.2 (fines de 2025). Para navegadores más viejos, el polyfill de scroll-timeline del CSS Working Group cubre lo básico, y se puede usar @supports (animation-timeline: scroll()) para progressive enhancement.

### ¿Las scroll-driven animations afectan el performance?

Las CSS scroll-driven animations corren en el compositor thread, completamente fuera del main thread. Son tan performantes como las transiciones CSS, sin ejecución de JavaScript, sin layout thrashing, sin forced reflows. El equivalente de la Web Animations API también corre del lado del compositor para propiedades transform y opacity.

### ¿Puedo combinar scroll-driven animations con animaciones regulares basadas en tiempo?

Sí. Un elemento puede tener múltiples animaciones, cada una con un timeline diferente. Se puede tener un efecto parallax scroll-driven junto con un brillo pulsante basado en tiempo usando el shorthand animation o propiedades animation-\* individuales con valores separados por coma.

## Tips

1. **Tip:** Siempre utilizar ` animation-timeline: view() ` en vez de Intersection Observer para animaciones de entrada. Corre en el compositor thread y no producirá jank aunque tu main thread esté ocupado parseando un JSON de 2MB. Las mediciones en un Android gama baja muestran que el approach con IO bajó a 14fps, mientras el de CSS se mantuvo en 60fps.
2. **Tip:** Utilizar ` animation-range: entry 10% entry 90% ` en vez de ` entry 0% entry 100% ` para animaciones de reveal. Empezar en 0% significa que la animación arranca cuando un solo pixel entra al viewport, que parece que no pasa nada. El rango 10-90% da una entrada visible y satisfactoria.
3. **Tip:** Depurar scroll timelines con ` animation-play-state: paused ` y ` animation-delay ` seteado a un valor negativo fijo. Esto congela la animación en un punto específico del timeline para que puedas inspeccionar estados intermedios sin scrollear.
4. **Tip:** Para animaciones scroll-driven horizontales, no olvidar de ` scroll-timeline-axis: x ` o ` view(inline) `. El default es block axis (vertical), y cuando tu animación de carrusel horizontal no funciona, esta es siempre la razón. Esta es la causa más común de problemas con animaciones de scroll horizontales.

- css
- scroll-animations
- web-animations-api
- scroll-timeline
- viewtimeline
- performance-frontend
- css-animations

[Volver al Blog](<https://lucioduran.com/es/blog>)
