wandres.dev
GSAP VII · ScrollTrigger: pin y scrub

scrub: el cabezal atado a la barra y el suavizado numérico

La diferencia entre scrub booleano y numérico, cómo se reinterpretan duraciones y eases bajo scrub, y qué hace exactamente el tween interno del suavizado.

⏱ 18 min

scrub: true desconecta la animación de su reloj y la enchufa a la barra de scroll. Ese cambio es más radical de lo que parece: propiedades que llevas usando desde el primer día —duration, ease, delay, repeat— cambian de significado o dejan de tener ninguno. Y scrub: 1 no es “lo mismo pero más suave”: añade una animación intermedia real, con su propio cabezal y su propia latencia, que resuelve un problema de sensación táctil y crea otro de sincronización.

🎯 Al terminar esta lección sabrás
  • Distinguir el scrub booleano del numérico y qué añade el segundo.
  • Reinterpretar duration, ease, delay y repeat bajo scrub.
  • Elegir el valor numérico de suavizado según el tipo de contenido animado.
  • Acceder al tween interno del scrub y saber cuándo hace falta.

El cabezal deja de correr solo

Sin scrub, una animación tiene un cabezal que avanza con el tiempo real, y ScrollTrigger solo le da órdenes de reproducción. Con scrub: true, el cabezal deja de avanzar por su cuenta: en cada actualización se le asigna la posición que dicta el progress de la instancia. Si el progress es 0,37, el cabezal se coloca en el 37% de la duración total. Si el usuario para, el cabezal se queda ahí. Si sube, retrocede.

Las consecuencias sobre las propiedades habituales son estas, y conviene tenerlas claras:

duration deja de significar segundos y pasa a ser un peso relativo dentro de la timeline. Solo cuentan las proporciones. Tres tweens de 1, 3 y 1 ocupan el 20%, el 60% y el 20% del recorrido de scroll, exactamente igual que tres tweens de 2, 6 y 2.

ease se convierte en la forma de la relación entre scroll y avance, y ahí es donde casi todo el mundo se equivoca. Un power2.out bajo scrub significa que la animación avanza mucho al principio del scroll y poco al final, lo cual se percibe como un elemento que responde de forma no lineal a tu mano. Los eases con rebote son peores: back.out hace que el elemento se pase de largo y vuelva, así que al hacer scroll hacia adelante lo ves retroceder. Para la mayoría de los scrubs, la respuesta correcta es ease: "none", y ponerlo por defecto ahorra disgustos.

delay no hace nada, porque no hay tiempo que retrasar. Para meter una pausa dentro de una secuencia con scrub se usa la posición dentro de la timeline, no el delay.

repeat y yoyo funcionan, pero producen una animación cuya duración total es el múltiplo, así que un repeat: 2 reparte el recorrido de scroll entre tres pasadas idénticas.

const tl = gsap.timeline({
  defaults: { ease: "none" },   // regla practica para todo scrub
  scrollTrigger: {
    trigger: ".escena",
    start: "top top",
    end: "+=2400",
    scrub: true,
    pin: true,
  },
});

tl.to(".capa-fondo", { yPercent: -20, duration: 3 })
  .to(".capa-media", { yPercent: -45, duration: 3 }, 0)
  .to(".capa-frente", { yPercent: -80, duration: 3 }, 0)
  .to(".titulo", { autoAlpha: 0, duration: 1 }, 1.5);

Ese ejemplo, con los tres 0 como tercer argumento, coloca las tres capas en paralelo desde el principio, y el título desaparece a mitad de la timeline. El paralaje sale de las tres velocidades distintas, no de tres instancias distintas.

El número: qué añade y qué cuesta

scrub: 0.8 no coloca el cabezal directamente en el progress. Lo que hace es crear un tween interno cuyo objetivo es el progress actual y que tarda ese número de segundos en alcanzarlo. Cuando el usuario mueve la rueda, el objetivo salta; el cabezal lo persigue con retraso.

Eso produce tres efectos, y los tres importan.

El primero es el que buscabas: la animación se siente pesada y continua en lugar de pegada al escalón discreto de la rueda del ratón. Una rueda de ratón no produce un scroll continuo, produce saltos de decenas de píxeles; sin suavizado, una animación con scrub avanza a saltos.

El segundo es que la animación sigue moviéndose después de que pare el scroll, exactamente ese número de segundos. Eso es lo que hace que onScrubComplete exista y lo que hace que el onUpdate de la instancia sea el sitio equivocado para leer el estado de la animación: la instancia dejó de actualizarse hace un segundo y la animación no.

El tercero es que introduce latencia deliberada. Con valores altos —a partir de 2 más o menos— el desfase entre lo que hace tu mano y lo que hace la pantalla se percibe como que la página va lenta, no como que va suave.

Valor Sensación Cuándo
true Pegado, responde al píxel Barras de progreso, indicadores, cualquier cosa donde la precisión importe
0.3 a 0.6 Ligeramente amortiguado Texto, elementos pequeños, revelados
1 a 1.5 Claramente pesado Escenas grandes, paralaje, imágenes de fondo
2 o más Perezoso Casi nunca. Se percibe como retraso, no como suavidad
💡
El tween interno es accesible

self.getTween() devuelve el tween del scrub, y self.getTween(true) el del snap. Sirve para dos cosas concretas: comprobar si el suavizado sigue alcanzando su objetivo con getTween().isActive(), y matarlo cuando quieres saltar a una posición sin que la animación persiga nada, algo necesario al implementar navegación por anclas dentro de una escena con scrub.

Cuándo el scrub no es la respuesta

Hay un error de diseño frecuente que ninguna configuración arregla: usar scrub para lo que en realidad es una transición de estado. Si lo que quieres es que una barra de navegación se encoja al bajar, eso tiene dos estados, no un continuo. Atarlo a un scrub significa que existe un estado intermedio para cada posición de scroll, y el usuario puede dejar la barra a medio encoger, que es una posición que tu diseño nunca contempló. Ahí lo correcto es un toggleActions o un callback.

La regla es directa: usa scrub cuando el valor animado sea genuinamente continuo respecto a la posición del scroll —un desplazamiento, una escala, un recorte, una rotación—, y usa modo evento cuando el resultado sea un estado discreto.

El segundo error es meter una animación cara dentro de un scrub. Con scrub, la animación se evalúa en cada frame de scroll, así que animar propiedades que provocan layout —width, height, top, margin— multiplica el coste por el número de frames. Todo lo que anime transform, opacity o filter sale gratis en comparación.

// Caro: dispara layout en cada frame de scroll
gsap.to(".panel", { width: "80vw", scrollTrigger: { scrub: true, /* ... */ } });

// Barato: el compositor se encarga
gsap.to(".panel", { scaleX: 0.8, scrollTrigger: { scrub: true, /* ... */ } });

Y el tercero es olvidar que un scrub con pin es una escena, no un elemento. Una escena que dura 4000 píxeles de scroll son cuatro pantallas de un móvil en las que el usuario no puede hacer nada más que avanzar. Es una decisión de producto tanto como técnica, y merece que alguien la tome a conciencia en lugar de que salga de copiar una demo.

El scrub numérico es un filtro paso bajo, y por eso mejora la sensación y empeora la sincronía

Lo que hace scrub: 1 tiene un nombre en procesamiento de señales, y ponerle ese nombre aclara todas sus rarezas de golpe. La posición del scroll es una señal muestreada de forma irregular: la rueda del ratón produce impulsos discretos de decenas de píxeles, el trackpad produce una señal casi continua, el arrastre táctil produce otra cosa distinta, y una pulsación de la tecla de página produce un único salto enorme. Enchufar esa señal cruda al cabezal de una animación es enchufar ruido de cuantización directamente a la pantalla. El tween interno del scrub es, funcionalmente, un filtro paso bajo de primer orden: atenúa las componentes de alta frecuencia —los escalones de la rueda— y deja pasar las de baja frecuencia —la intención real de moverse por la página—. De ahí salen sus dos propiedades características, que no son un fallo sino la definición de lo que es un filtro. La primera es que introduce retardo de grupo: la salida va detrás de la entrada, y por eso la animación sigue moviéndose cuando la entrada ya se detuvo. La segunda es que no puede distinguir un escalón de la rueda de un movimiento real corto: si el usuario da un toque diminuto e intencionado, el filtro también lo suaviza, y por eso una barra de progreso con scrub: 1 se siente imprecisa. Elegir el número es exactamente elegir la frecuencia de corte, y como en cualquier filtro, no existe el valor que suavice el ruido sin retrasar la señal. Lo que existe es una elección informada sobre qué te duele más en cada caso. Para una barra de progreso duele el retardo, así que true. Para una escena de paralaje a pantalla completa duele el escalonado, así que 1. Y cuando alguien te pida “suave pero que responda al instante”, ya sabes que está pidiendo un filtro con retardo cero, que no existe.

⚔️ Calibrar el filtro
  1. Monta la misma animación con scrub: true, 0.5, 1 y 3, y recórrela con rueda de ratón y con trackpad. Anota cuál se siente mejor con cada dispositivo.
  2. Pon ease: "back.out" en un tween con scrub y describe qué ocurre al hacer scroll hacia adelante.
  3. Con scrub: 1, mete un console.log en el onUpdate de la instancia y otro en el onUpdate de la animación. Para el scroll de golpe y compara cuál sigue disparándose.
  4. Anima width y luego scaleX sobre el mismo elemento con scrub, y compara el gráfico de frames del panel de rendimiento.
  5. Coge una barra de navegación con dos estados que alguien haya atado a un scrub y conviértela a toggleActions.