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":
/* 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:
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:
/* 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:
/* 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:
.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:
// 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:
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:
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:
.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:
/* 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
.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
: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.