Saltar al contenido

Animaciones CSS basadas en scroll sin JavaScript

Guía práctica para crear animaciones de scroll de alto rendimiento con timelines nativos de CSS, view transitions y la propiedad animation-timeline.

5 min de lectura
Efectos de animación fluidos basados en el desplazamiento en una página web moderna

El fin de las animaciones de scroll controladas por JavaScript

Durante una década, las animaciones de scroll requirieron JavaScript. Escuchabas los eventos de scroll, calculabas posiciones y actualizabas estilos de forma imperativa. Bibliotecas como GSAP ScrollTrigger, Framer Motion e Intersection Observer lo hicieron manejable, pero el enfoque fundamental —JavaScript secuestrando el hilo principal para impulsar efectos visuales— siempre fue un compromiso de rendimiento.

Las animaciones CSS basadas en el desplazamiento cambian esto por completo. La propiedad animation-timeline conecta las animaciones CSS directamente con el progreso del scroll. El navegador maneja los cálculos en el hilo del compositor, ofreciendo efectos de scroll a 60 fps sin una sola línea de JavaScript. No es un polyfill ni un truco: es una especificación del W3C con un soporte de navegadores cada vez mayor.

Animaciones de progreso de scroll: los conceptos básicos

Una animación vinculada al scroll mapea el progreso de la animación de un elemento a la posición de scroll de un contenedor. En lugar de ejecutarse en el tiempo (segundos), la animación se ejecuta en el espacio (distancia de scroll).

csscss
/* ❌ Old approach: JavaScript scroll listener */
/* 
  window.addEventListener('scroll', () => {
    const progress = window.scrollY / document.body.scrollHeight;
    element.style.transform = `scaleX(${progress})`;
  });
  // Runs on main thread, causes jank, needs throttling
*/
csscss
/* ✅ New approach: Pure CSS scroll animation */
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: linear-gradient(90deg, #3b82f6, #8b5cf6);
  transform-origin: left;
  animation: grow-progress linear both;
  animation-timeline: scroll();
}
 
@keyframes grow-progress {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

La declaración animation-timeline: scroll() reemplaza la progresión basada en el tiempo por una basada en el scroll. El modo de relleno both asegura que el estado de la animación persista en ambos extremos. Sin JavaScript, sin listener de scroll, sin tirones.

Timelines de scroll con nombre para contenedores anidados

La función scroll() usa por defecto el ancestro desplazable más cercano. Para tener más control, los timelines de scroll con nombre permiten vincular animaciones a elementos desplazables específicos.

csscss
.scroll-container {
  overflow-y: auto;
  height: 600px;
  scroll-timeline-name: --container-scroll;
  scroll-timeline-axis: block;
}
 
.parallax-background {
  animation: parallax-shift linear both;
  animation-timeline: --container-scroll;
}
 
@keyframes parallax-shift {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(-200px);
  }
}
 
.fade-header {
  animation: header-fade linear both;
  animation-timeline: --container-scroll;
  animation-range: 0% 30%;
}
 
@keyframes header-fade {
  from {
    opacity: 1;
    transform: translateY(0);
  }
  to {
    opacity: 0;
    transform: translateY(-40px);
  }
}

La propiedad animation-range es potente. Limita cuándo se ejecuta la animación dentro del timeline de scroll. animation-range: 0% 30% significa que la animación se completa cuando el usuario ha recorrido el 30 % del contenedor. Esto crea efectos escalonados donde distintos elementos se animan en diferentes posiciones de scroll.

Timelines de vista: animar elementos al entrar

Los timelines de vista disparan animaciones según la visibilidad de un elemento dentro de su ancestro desplazable. Esto reemplaza el patrón de Intersection Observer para las animaciones de entrada.

csscss
/* ❌ Old: Intersection Observer + class toggling */
/*
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
      }
    });
  });
  document.querySelectorAll('.animate-on-scroll').forEach(el => {
    observer.observe(el);
  });
*/
csscss
/* ✅ New: Pure CSS view timeline */
.card {
  animation: slide-up linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
 
@keyframes slide-up {
  from {
    opacity: 0;
    transform: translateY(80px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

La función view() crea un timeline basado en la intersección del elemento con su viewport. animation-range: entry 0% entry 100% significa que la animación se ejecuta desde que el elemento entra por primera vez en el viewport hasta que es totalmente visible.

Los rangos de los timelines de vista tienen fases con nombre:

csscss
.staggered-reveal {
  animation: reveal linear both;
  animation-timeline: view();
}
 
/* Entry: element entering the viewport from below */
.entry-animation {
  animation-range: entry 0% entry 100%;
}
 
/* Cover: element covering the viewport (full visibility) */
.cover-animation {
  animation-range: cover 0% cover 100%;
}
 
/* Exit: element leaving the viewport from above */
.exit-animation {
  animation-range: exit 0% exit 100%;
}
 
/* Contain: element fully contained within viewport */
.contain-animation {
  animation-range: contain 0% contain 100%;
}
 
@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(60px) scale(0.95);
    filter: blur(4px);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
    filter: blur(0);
  }
}

Construyendo una sección hero basada en el scroll

Combinar varias animaciones basadas en el desplazamiento crea secciones hero ricas y de alto rendimiento sin nada de JavaScript.

htmlhtml
<section class="hero">
  <div class="hero-background"></div>
  <h1 class="hero-title">Build Better Software</h1>
  <p class="hero-subtitle">Engineering excellence at every layer</p>
  <div class="scroll-indicator">
    <span class="scroll-arrow"></span>
  </div>
</section>
 
<section class="content">
  <article class="feature-card">Feature One</article>
  <article class="feature-card">Feature Two</article>
  <article class="feature-card">Feature Three</article>
</section>
csscss
.hero {
  position: relative;
  height: 100vh;
  display: grid;
  place-items: center;
  overflow: hidden;
}
 
.hero-background {
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
  animation: bg-parallax linear both;
  animation-timeline: scroll();
  animation-range: 0vh 100vh;
}
 
@keyframes bg-parallax {
  to {
    transform: scale(1.2) translateY(-10%);
    filter: brightness(0.4);
  }
}
 
.hero-title {
  font-size: clamp(2rem, 6vw, 5rem);
  color: white;
  animation: title-exit linear both;
  animation-timeline: scroll();
  animation-range: 0vh 60vh;
}
 
@keyframes title-exit {
  to {
    opacity: 0;
    transform: translateY(-100px) scale(0.8);
    filter: blur(8px);
  }
}
 
.hero-subtitle {
  color: rgba(255, 255, 255, 0.7);
  animation: subtitle-exit linear both;
  animation-timeline: scroll();
  animation-range: 0vh 40vh;
}
 
@keyframes subtitle-exit {
  to {
    opacity: 0;
    transform: translateY(-60px);
  }
}
 
.scroll-indicator {
  position: absolute;
  bottom: 2rem;
  animation: fade-indicator linear both;
  animation-timeline: scroll();
  animation-range: 0vh 20vh;
}
 
@keyframes fade-indicator {
  to {
    opacity: 0;
  }
}
 
/* Feature cards enter with staggered view animations */
.feature-card {
  animation: card-enter linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 80%;
}
 
.feature-card:nth-child(2) {
  animation-delay: 0.1s; /* Stagger effect */
}
 
.feature-card:nth-child(3) {
  animation-delay: 0.2s;
}
 
@keyframes card-enter {
  from {
    opacity: 0;
    transform: translateY(60px) rotateX(10deg);
  }
  to {
    opacity: 1;
    transform: translateY(0) rotateX(0deg);
  }
}

El texto del hero se desvanece a diferentes velocidades a medida que se hace scroll, creando un efecto de paralaje con profundidad. El fondo hace zoom y se atenúa. Las tarjetas de características se animan al entrar en el viewport. Todo esto se ejecuta en el hilo del compositor.

Combinación con la View Transitions API

La View Transitions API complementa las animaciones basadas en el desplazamiento al proporcionar transiciones suaves entre estados de página o navegaciones.

csscss
/* Opt-in to view transitions */
@view-transition {
  navigation: auto;
}
 
/* Style the transition pseudo-elements */
::view-transition-old(root) {
  animation: fade-out 0.3s ease-out;
}
 
::view-transition-new(root) {
  animation: fade-in 0.3s ease-in;
}
 
/* Named transitions for specific elements */
.product-image {
  view-transition-name: product-hero;
}
 
::view-transition-old(product-hero) {
  animation: scale-down 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
 
::view-transition-new(product-hero) {
  animation: scale-up 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
 
@keyframes scale-down {
  to {
    transform: scale(0.8);
    opacity: 0;
  }
}
 
@keyframes scale-up {
  from {
    transform: scale(1.2);
    opacity: 0;
  }
}
 
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
 
@keyframes fade-in {
  from {
    opacity: 0;
  }
}

Mejora progresiva y alternativas

No todos los navegadores admiten aún las animaciones basadas en el desplazamiento. Usa consultas @supports para añadirlas como mejora progresiva sobre una experiencia base que funcione en todas partes.

csscss
/* Base experience — works in all browsers */
.card {
  opacity: 1;
  transform: none;
  transition: opacity 0.3s, transform 0.3s;
}
 
/* Enhanced experience — scroll-driven animation */
@supports (animation-timeline: scroll()) {
  .card {
    animation: card-reveal linear both;
    animation-timeline: view();
    animation-range: entry 10% entry 90%;
  }
 
  @keyframes card-reveal {
    from {
      opacity: 0;
      transform: translateY(40px);
    }
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }
}
 
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
  .card {
    animation: none !important;
    opacity: 1;
    transform: none;
  }
}

La media query prefers-reduced-motion no es negociable. Las animaciones basadas en el desplazamiento pueden provocar mareos en usuarios con trastornos vestibulares. Ofrece siempre una alternativa de movimiento reducido que elimine o minimice la animación.

Puntos clave

Las animaciones CSS basadas en el desplazamiento eliminan el mayor punto de fricción de la animación web: la dependencia de JavaScript. Los timelines de progreso de scroll, los timelines de vista y los rangos de animación te dan un control preciso sobre cuándo y cómo se animan los elementos en relación con la posición de scroll, todo ejecutándose en el hilo del compositor a 60 fps.

El cambio de modelo mental es significativo. En lugar de "escuchar eventos de scroll y calcular posiciones", defines keyframes y declaras qué contexto de scroll los impulsa. El navegador se encarga de la sincronización.

La mejora progresiva es la estrategia de despliegue. Escribe una experiencia base que funcione sin animaciones de scroll, añádelas en capas con @supports y respeta siempre prefers-reduced-motion. Los usuarios que reciben la experiencia mejorada ven interacciones suaves como la seda. Los que no, siguen viendo una página funcional. Así es como debería funcionar la web.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX