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).
.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: pausedcongela la animación. No avanza con el reloj.animation-delaynegativo la “adelanta”: un retardo de-0.5sen 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 variable —calc(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 deelemento.style.color = ...que viste en el capítulo del DOM, porque las variables CSS empiezan por--y no son propiedades normales: para esas se usasetProperty. (document.documentElementes 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:
// 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.scrollYes cuánto has bajado;document.body.offsetHeight - window.innerHeightes 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) | |
|---|---|---|
| JavaScript | Ninguno | Una línea |
| Soporte | Reciente (2023+) | Cualquier navegador |
| Qué anima | Cualquier @keyframes | Cualquier @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.
// 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`.
Paso 2: Fino
- Listener `passive: true` para que el scroll no se entrecorte.
- Valor acotado a 0–1 (`Math.min`/`Math.max`).
- Se actualiza también al cargar, no solo al primer scroll.
Paso 3: Accesible
- Respeta `prefers-reduced-motion`: si está activo, no ata nada al scroll.
- La cabecera queda en su estado base (sin colapsar) para quien pidió menos movimiento.
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.
// SOLUCIÓN MEJOR — la misma idea, más fina.
// - Listener `passive: true`: le promete al navegador que no vas a bloquear el
// scroll, así que no se entrecorta.
// - Valor acotado a 0–1 con Math.min/Math.max: nada de pasarse en el rebote.
// - Una función con nombre que se llama también al cargar, para que la cabecera
// arranque ya en su sitio, no solo al primer scroll.
// document.documentElement es el <html>: las custom properties puestas aquí
// las lee todo el CSS de la página.
const raiz = document.documentElement;
function actualizarScroll() {
// Recorrido máximo: altura total del contenido menos la altura visible.
const total = document.body.offsetHeight - window.innerHeight;
// Progreso 0-1: píxeles bajados entre recorrido máximo.
// El ternario evita dividir entre 0 si la página es más corta que la ventana.
const progreso = total > 0 ? window.scrollY / total : 0;
// Escribe --scroll en el raíz acotada a 0-1 para evitar desbordamientos en rebote.
raiz.style.setProperty("--scroll", Math.min(1, Math.max(0, progreso)));
}
// Llamamos al cargar: la cabecera arranca en su estado correcto sin esperar scroll.
actualizarScroll();
// passive: true promete que no llamaremos a preventDefault(); el scroll va fluido.
window.addEventListener("scroll", actualizarScroll, { passive: true }); Por qué es mejor que el anterior
- `passive: true` le promete al navegador que no vas a bloquear el scroll: se mueve fluido.
- El valor va acotado a 0–1 con `Math.min`/`Math.max`: nada de pasarse en el rebote.
- Una función con nombre, llamada también al cargar: la cabecera arranca en su sitio, no espera al primer scroll.
// SOLUCIÓN EXCELENTE — todo lo de "mejor", y además accesible.
// Respeta a quien ha pedido menos movimiento en su sistema: si
// prefers-reduced-motion está activo, no atamos nada al scroll. La variable
// --scroll se queda en 0 y la cabecera permanece en su estado base (sin colapsar).
// El efecto es un extra; nunca debe imponerse a quien no lo quiere.
// document.documentElement es el <html>: las custom properties puestas aquí
// las lee todo el CSS de la página.
const raiz = document.documentElement;
// window.matchMedia() evalúa una media query desde JavaScript.
// "(prefers-reduced-motion: reduce)" detecta si el usuario pidió menos movimiento
// en la configuración de accesibilidad de su sistema operativo.
// .matches devuelve true si esa preferencia está activa.
const prefiereMenosMovimiento = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
// Solo atamos el scroll si el usuario NO ha pedido menos movimiento.
// Si prefiereMenosMovimiento es true, no tocamos nada: --scroll vale 0
// y la cabecera se queda en su estado base (grande, sin colapsar).
if (!prefiereMenosMovimiento) {
const actualizarScroll = () => {
// Recorrido máximo: altura total del contenido menos la altura visible.
const total = document.body.offsetHeight - window.innerHeight;
// Progreso 0-1: píxeles bajados entre recorrido máximo.
// El ternario evita dividir entre 0 si la página cabe entera en la ventana.
const progreso = total > 0 ? window.scrollY / total : 0;
// Escribe --scroll acotada a 0-1 para evitar desbordamientos en rebote móvil.
raiz.style.setProperty("--scroll", Math.min(1, Math.max(0, progreso)));
};
// Llamamos al cargar: la cabecera arranca en su estado sin esperar al scroll.
actualizarScroll();
// passive: true promete que no bloqueamos el scroll; el navegador va fluido.
window.addEventListener("scroll", actualizarScroll, { passive: true });
} Por qué es mejor que el anterior
- Respeta `prefers-reduced-motion`: si está activo, no ata nada al scroll y la cabecera se queda en su estado base.
- Es el mismo motor que «mejor», con la accesibilidad que el artículo original de este truco se dejaba fuera.
- El movimiento decorativo es un extra: que sea opcional para quien lo necesita es justo lo que lo hace excelente.
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.