learning-front

Nivel 2 · JavaScript: fundamentos del lenguaje

BONUS: Animaciones al ritmo del scroll (CSS + una pizca de JS)

La otra cara de las scroll-driven animations: liga cualquier @keyframes al scroll de una forma que funciona en TODOS los navegadores, con unas pocas líneas de JavaScript. Y su trade-off frente a la versión de CSS puro.

Extra opcional. En el Nivel 1 viste la barra de progreso hecha con animation-timeline: scroll(): CSS puro, cero JavaScript, pero soporte reciente. Aquí está su otra cara: el truco clásico para ligar una animación al scroll que funciona en cualquier navegador, a cambio de una línea de JS. Ahora que sabes algo de JavaScript, ya puedes con él.

La idea: rebobinar una animación pausada#

El punto de partida es una animación normal con @keyframes, pero con un giro: no la dejamos correr.

calc() hace aritmética en CSS: te deja operar con valores y unidades directamente en la hoja de estilos, igual que viste en el Nivel 1 para calcular anchos y espacios. Aquí lo usamos para convertir el progreso de scroll (un número entre 0 y 1, sin unidad) en un retardo de tiempo: calc(var(--scroll, 0) * -1s) multiplica ese progreso por -1s y obtiene un retardo negativo de entre 0s (arriba del todo) y -1s (abajo del todo).

css
.cabecera {
  /* nombre del @keyframes que define los fotogramas */
  animation-name: colapsar;
  /* cuánto dura un ciclo completo de 0% a 100% */
  animation-duration: 1s;
  /* congela la animación: no avanza con el reloj */
  animation-play-state: paused;
  /* calc() opera con valores CSS: var(--scroll, 0) lee la custom property --scroll
     (0 si no existe aún); multiplicar por -1s da un retardo negativo de 0s a -1s.
     Recorremos así toda la animación, fotograma a fotograma, según el scroll. */
  animation-delay: calc(var(--scroll, 0) * -1s);
}
@keyframes colapsar {
  from {                                       /* estado inicial: barra alta */
    padding-top: 2rem;
    padding-bottom: 2rem;
  }
  to {                                         /* estado final: barra compacta */
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
  }
}

Dos piezas hacen la magia:

  • animation-play-state: paused congela la animación. No avanza con el reloj.
  • animation-delay negativo la “adelanta”: un retardo de -0.5s en una animación de 1s la deja congelada en su mitad. Es como decir “haz como si ya llevara medio segundo corriendo”.

Si ese retardo lo controla una variablecalc(var(--scroll) * -1s)—, entonces moviendo --scroll de 0 a 1 recorremos la animación entera, fotograma a fotograma. Y --scroll la movemos nosotros.

Mover --scroll desde JavaScript#

Aquí entra la pizca de lógica. Necesitas cuatro piezas que el navegador te da hechas:

  • window.scrollY: cuántos píxeles has bajado desde arriba del todo.
  • window.innerHeight: la altura visible de la ventana (lo que cabe en pantalla).
  • document.body.offsetHeight: la altura total de la página, de arriba abajo.
  • document.documentElement.style.setProperty('--scroll', valor): la forma de escribir una custom property de CSS desde JavaScript. Es distinta de elemento.style.color = ... que viste en el capítulo del DOM, porque las variables CSS empiezan por -- y no son propiedades normales: para esas se usa setProperty. (document.documentElement es el <html>, el elemento raíz donde el CSS de toda la página puede leer la variable.)

Con esas piezas, el progreso es una división. Un ejemplo con números concretos: si la página mide 2000px de alto y la ventana 800px, lo máximo que puedes bajar es 2000 − 800 = 1200px. Si en ese momento has bajado 600px, el progreso es 600 / 1200 = 0.5, justo la mitad. Eso es lo que guardamos en --scroll:

En cada scroll calculamos ese número de 0 a 1 y lo escribimos en la variable CSS:

javascript
// document.documentElement es el elemento <html>: el raíz del documento.
// Las custom properties puestas aquí las lee todo el CSS de la página.
const raiz = document.documentElement;

function actualizar() {
  // document.body.offsetHeight: altura total del contenido de la página en píxeles.
  // window.innerHeight: altura visible de la ventana en píxeles.
  // Su diferencia es cuánto se puede bajar en total (el "recorrido máximo").
  const total = document.body.offsetHeight - window.innerHeight;

  // window.scrollY: cuántos píxeles se ha bajado desde arriba del todo.
  // Dividirlo entre total da el progreso: 0 arriba, 1 abajo.
  // Si total fuera 0 (página más corta que la ventana), evitamos dividir entre 0.
  const progreso = total > 0 ? window.scrollY / total : 0;

  // style.setProperty escribe --scroll en el elemento raíz, donde el CSS la lee.
  // Math.min(1, Math.max(0, ...)) la acota a 0–1: en el "rebote" de algunos
  // navegadores móviles, scrollY puede pasar de total, y así no nos salimos.
  raiz.style.setProperty("--scroll", Math.min(1, Math.max(0, progreso)));
}

// Llamamos al cargar para que la cabecera arranque ya en su sitio correcto
// (si la página carga a mitad del scroll, no espera al primer evento).
actualizar();

// { passive: true } le promete al navegador que este listener nunca llamará
// a event.preventDefault(), así que puede ejecutar el scroll sin esperar.
// Sin esto, en páginas pesadas el scroll puede dar tirones.
window.addEventListener("scroll", actualizar, { passive: true });
  • window.scrollY es cuánto has bajado; document.body.offsetHeight - window.innerHeight es cuánto se puede bajar en total. Su división es el progreso de 0 a 1.
  • style.setProperty("--scroll", ...) escribe esa variable en el elemento raíz, de donde el CSS la lee.
  • Math.min(1, Math.max(0, ...)) la mantiene entre 0 y 1.
  • { passive: true } le dice al navegador que este listener no va a bloquear el scroll, para que no se entrecorte.

Y ya está. El navegador se encarga del resto: cada vez que cambia --scroll, recoloca la animación pausada en el fotograma correspondiente.

El trade-off, en claro#

Tienes dos formas de animar al ritmo del scroll, y eliges según a quién sirves:

animation-timeline: scroll()Pausar + animation-delay (esta)
JavaScriptNingunoUna línea
SoporteReciente (2023+)Cualquier navegador
Qué animaCualquier @keyframesCualquier @keyframes

No hay una “mejor”: hay una más limpia (CSS puro) y una más compatible (con JS). Saber que existen las dos, y por qué elegirías cada una, es lo que de verdad vale.

Accesibilidad: no a todo el mundo#

El movimiento marea o distrae a algunas personas, y el sistema operativo deja pedir “menos movimiento”. Respétalo: si esa preferencia está activa, no ates nada al scroll.

javascript
// window.matchMedia() evalúa una media query CSS desde JavaScript.
// Le pasamos "(prefers-reduced-motion: reduce)": es la misma media query que
// usarías en CSS con @media, pero aquí obtenemos un objeto MediaQueryList.
// .matches devuelve true si esa preferencia está activa en el sistema del usuario.
const prefiereMenos = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// Solo atamos la animación al scroll si el usuario NO ha pedido menos movimiento.
// Si prefiereMenos es true, --scroll se queda en 0 y la cabecera permanece en su estado base.
if (!prefiereMenos) {
  // ...solo aquí enganchamos el scroll
}

El artículo original de este truco no lo mencionaba. Tú sí: un efecto decorativo nunca debe imponerse a quien ha pedido que pare.

Pruébalo#

Haz scroll dentro del preview y mira la cabecera compactarse: el retrato se encoge y la barra adelgaza. En index.js, prueba a quitar Math.min/Math.max o a cambiar el cálculo y observa cómo se descontrola.

Comprueba lo que sabes#

Pregunta 1 de 4

¿Por qué la animación de la cabecera no se reproduce sola?

Tu turno#

La cabecera no colapsa porque nadie mueve --scroll. Escribe ese JavaScript en index.js: calcula el progreso de 0 a 1 y ponlo en la variable, en cada scroll y al cargar. Cuando lo tengas, despliega las soluciones y fíjate en el salto de “que se mueva” a “que respete a quien pidió menos movimiento”.

Ejercicio · en esta página

Cabecera de perfil que colapsa al hacer scroll

El CSS ya tiene la cabecera del perfil con su animación pausada, lista para que la "rebobines" con la variable --scroll: al bajar, el retrato se encoge y la barra adelgaza. Tu tarea es el JavaScript (index.js): calcular el progreso de scroll (0 a 1) y ponerlo en --scroll, en cada scroll y también al cargar.

Paso 1: Que se mueva

  • La cabecera se compacta al hacer scroll, y vuelve a su tamaño al subir.
  • Pones `--scroll` con `document.documentElement.style.setProperty`.
Ver soluciones
// SOLUCIÓN OK — funciona, pero es tosca.
// Recalcula la altura de la página en CADA scroll (trabajo de más), no acota el
// valor (en el "rebote" de algunos navegadores puede pasar de 1 o bajar de 0) y
// el listener no es passive, así que puede dar tirones en una página pesada.
window.addEventListener("scroll", () => {
  // document.body.offsetHeight: altura total del contenido de la página en píxeles.
  // window.innerHeight: altura visible de la ventana en píxeles.
  // La diferencia entre ambos es el recorrido máximo: cuánto se puede bajar en total.
  const total = document.body.offsetHeight - window.innerHeight;

  // window.scrollY: cuántos píxeles se ha bajado desde la parte superior de la página.
  // Dividirlo entre total da el progreso (0 arriba, 1 abajo).
  // Sin acotar: en el "rebote" de algunos navegadores puede salirse del rango 0-1.
  const progreso = window.scrollY / total;

  // document.documentElement es el <html>, el elemento raíz de la página.
  // style.setProperty("--scroll", valor) escribe la custom property --scroll en él.
  // Las custom properties con -- no son propiedades CSS normales: se escriben siempre
  // con setProperty, no con asignación directa (elemento.style.propiedad = ...).
  document.documentElement.style.setProperty("--scroll", progreso);
});

Por qué este nivel

  • Funciona: la cabecera se compacta con el scroll. Pero recalcula la altura de la página en cada evento, que es trabajo de más.
  • No acota el valor a 0–1: en el "rebote" de algunos navegadores, `--scroll` puede pasarse de 1 o bajar de 0.
  • El listener no es `passive`: en una página pesada, el scroll puede entrecortarse.

Ya tienes las dos caras de animar con el scroll: la de CSS puro y la universal con una pizca de JS. Un buen ejemplo de algo que verás todo el rato en frontend: rara vez hay una única forma correcta, sino opciones con su precio.