wandres.dev
GSAP VI · ScrollTrigger: el modelo

Los callbacks y el objeto self que reciben

Los seis callbacks de posición, onRefresh y sus parientes, y el catálogo completo de propiedades y métodos del objeto que todos reciben como único parámetro.

⏱ 19 min

toggleActions cubre el caso en que lo único que quieres es controlar una animación de GSAP. En cuanto necesitas cambiar el estado de tu aplicación, reproducir un vídeo, cargar datos o reaccionar a la velocidad del scroll, hay que bajar un piso. Los callbacks de ScrollTrigger son ese piso, y todos comparten una firma idéntica: reciben un único parámetro, la propia instancia, que trae consigo el estado completo del sistema en ese instante. Aprender qué hay dentro de ese objeto es aprender todo lo que ScrollTrigger sabe.

🎯 Al terminar esta lección sabrás
  • Distinguir los cuatro callbacks de posición y en qué se diferencian de onToggle y onUpdate.
  • Leer progress, direction e isActive y saber cuándo cada uno es la respuesta correcta.
  • Usar getVelocity() y scroll() para efectos que dependen de la dinámica del scroll.
  • Saber por qué onUpdate no es el sitio para reaccionar a una animación con scrub numérico.

Los callbacks de posición

Los cuatro primeros se corresponden exactamente con las cuatro posiciones de toggleActions: onEnter, onLeave, onEnterBack y onLeaveBack. Se disparan al cruzar cada frontera en cada sentido, y no hay diferencia semántica alguna con las palabras clave; simplemente puedes ejecutar código arbitrario en vez de una acción predefinida.

ScrollTrigger.create({
  trigger: ".video-seccion",
  start: "top 70%",
  end: "bottom 30%",
  onEnter: () => video.play(),
  onLeave: () => video.pause(),
  onEnterBack: () => video.play(),
  onLeaveBack: () => video.pause(),
});

Ese patrón —dos pares idénticos— aparece tan a menudo que existe un quinto callback pensado justo para él. onToggle se dispara cuando la instancia cambia de inactiva a activa o al revés, y como el objeto que recibe trae isActive, un solo callback sustituye a los cuatro.

ScrollTrigger.create({
  trigger: ".video-seccion",
  start: "top 70%",
  end: "bottom 30%",
  onToggle: (self) => (self.isActive ? video.play() : video.pause()),
});

Hay un matiz que conviene conocer porque produce un fallo real y silencioso: si el usuario hace scroll tan rápido que en un solo tick la posición pasa de antes del start a después del end, la instancia nunca llegó a estar activa, el estado no cambió, y onToggle no se dispara. Los cuatro callbacks de posición sí, porque miden cruces de frontera y ambas fronteras se cruzaron. Si tu lógica tiene que ejecutarse sí o sí en cada paso, usa los cuatro; si describe un estado, usa onToggle.

El sexto es onUpdate, y se dispara cada vez que cambia el progress, es decir, en cada tick en que la barra de scroll se ha movido dentro del intervalo. Es el callback de grano fino, y también el más fácil de usar mal.

El objeto self, propiedad a propiedad

Todos los callbacks reciben la instancia como único parámetro. Estas son sus propiedades, y ninguna es de adorno:

Propiedad Tipo Qué contiene
progress Número Recorrido del intervalo, de 0 a 1
direction Número 1 bajando, -1 subiendo
isActive Booleano Si la barra está entre start y end
start Número Posición inicial en píxeles de scroll
end Número Posición final en píxeles de scroll
trigger Elemento El elemento disparador ya resuelto, no el selector
animation Tween o Timeline La animación asociada, si la hay
scroller Elemento o window El contenedor con scroll
vars Objeto La configuración tal cual la escribiste
pin Elemento El elemento fijado, si lo hay

Y sus métodos más útiles: getVelocity() devuelve la velocidad del scroll en píxeles por segundo, con signo; scroll() lee o escribe la posición de la barra del scroller asociado; refresh() recalcula solo esa instancia; getTween() devuelve el tween interno del scrub, o el del snap si le pasas true; labelToScroll(nombre) traduce una etiqueta de la timeline asociada a la posición de scroll que la produce; previous() y next() devuelven las instancias vecinas en el orden de refresco.

ScrollTrigger.create({
  trigger: ".seccion",
  start: "top bottom",
  end: "bottom top",
  onUpdate: (self) => {
    // Inclinar un elemento segun lo rapido que se este haciendo scroll
    const skew = gsap.utils.clamp(-12, 12, self.getVelocity() / -180);
    gsap.to(".tarjeta", { skewY: skew, duration: 0.4, ease: "power3.out" });
  },
});

labelToScroll es la pieza que convierte una timeline con scrub en algo navegable: si tu timeline tiene etiquetas, puedes construir un índice cuyos enlaces lleven a la posición exacta de scroll que corresponde a cada capítulo.

const tl = gsap.timeline({
  scrollTrigger: { trigger: ".historia", start: "top top", end: "+=4000", scrub: 1, pin: true },
});
tl.addLabel("origen").to(".a", { xPercent: -100 })
  .addLabel("crisis").to(".b", { xPercent: -100 })
  .addLabel("cierre").to(".c", { xPercent: -100 });

document.querySelector("#ir-a-crisis").addEventListener("click", () => {
  window.scrollTo({ top: tl.scrollTrigger.labelToScroll("crisis"), behavior: "smooth" });
});

Fíjate en que la instancia es accesible desde la animación con tl.scrollTrigger. Es la vía inversa a self.animation y evita tener que guardar referencias en variables sueltas.

Los callbacks del ciclo de vida

Además de los de posición hay tres que hablan del sistema, no del scroll.

onRefresh se dispara cuando la instancia recalcula sus posiciones, que es donde debes poner cualquier medida propia que dependa del layout. Todo lo que calcules en el cuerpo del módulo se calcula una vez y se queda obsoleto en el primer resize; lo que calcules en onRefresh se recalcula con el resto.

onScrubComplete se dispara cuando un scrub numérico termina de alcanzar su objetivo, y solo existe si el scrub es un número. onSnapComplete se dispara al terminar un ajuste por snap, y no se dispara si el usuario interrumpe el ajuste tocando el scroll.

let anchoCarril = 0;

ScrollTrigger.create({
  trigger: ".carril",
  start: "top top",
  end: () => "+=" + anchoCarril,
  onRefresh: () => {
    anchoCarril = document.querySelector(".carril").scrollWidth - window.innerWidth;
  },
});

Y a nivel global existe ScrollTrigger.addEventListener(), que escucha sucesos que no pertenecen a ninguna instancia concreta: "scrollStart", "scrollEnd", "refreshInit", "refresh", "revert" y "matchMedia". "refreshInit" es especialmente útil, porque se dispara antes de que nada se mida, que es el único momento seguro para deshacer transformaciones que falsearían la medición.

⚠️
onUpdate no es el sitio para reaccionar a una animación con scrub numérico

Con scrub: 1, la animación sigue moviéndose durante un segundo después de que el scroll se haya detenido, porque el cabezal está alcanzando su objetivo. onUpdate de la instancia deja de dispararse en cuanto para el scroll, así que si lo usas para leer el estado de la animación te quedarás con la foto de hace un segundo. Si lo que quieres es reaccionar a los cambios de la animación, pon el onUpdate en la animación, no en el ScrollTrigger.

Cuándo el callback correcto es ninguno

Merece la pena decirlo explícitamente porque es la causa más común de páginas que se atascan: onUpdate se ejecuta en cada frame durante el scroll, y todo lo que metas ahí se paga sesenta veces por segundo, en el hilo principal, mientras el usuario está haciendo la única acción en la que el jank se nota inmediatamente.

Escribir element.style.transform en onUpdate está bien. Leer getBoundingClientRect() en onUpdate fuerza un recálculo de layout en cada frame. Modificar innerHTML es peor todavía. Y crear un tween nuevo en cada onUpdate es catastrófico: acabas con cientos de tweens compitiendo por la misma propiedad.

Cuando la reacción es una animación, casi siempre la respuesta correcta no es un callback sino un segundo ScrollTrigger con scrub, que hace lo mismo con código optimizado y sin que tú toques el hilo principal. Y cuando de verdad necesitas un cálculo por frame, gsap.quickTo() te da un setter reutilizable que evita crear un tween nuevo cada vez.

// En lugar de un gsap.to dentro de onUpdate
const setSkew = gsap.quickTo(".tarjeta", "skewY", { duration: 0.4, ease: "power3.out" });

ScrollTrigger.create({
  trigger: ".seccion",
  start: "top bottom",
  end: "bottom top",
  onUpdate: (self) => setSkew(gsap.utils.clamp(-12, 12, self.getVelocity() / -180)),
});
El objeto self es la prueba de que ScrollTrigger no tiene estado oculto, y eso te da un depurador gratis

Una cosa que distingue a las bibliotecas que envejecen bien de las que se vuelven inmanejables es si su estado interno es inspeccionable. ScrollTrigger tomó una decisión que parece cosmética y no lo es: en vez de pasar a cada callback los datos que ese callback “necesita” —un progreso aquí, una dirección allá, un evento sintético más allá—, pasa siempre el objeto completo, el mismo que te devolvió al crearlo, el mismo que sale de ScrollTrigger.getAll(). No hay dos representaciones del estado, no hay un objeto de evento que sea una proyección parcial, no hay campos privados que solo conozca el plugin. La consecuencia inmediata es que cualquier callback es un punto de parada válido: pones un debugger dentro de un onUpdate y tienes delante el sistema entero, no un resumen. La consecuencia menos obvia es que puedes escribir herramientas de diagnóstico genéricas que funcionan sobre cualquier instancia sin conocerla, porque todas exponen la misma superficie: la tabla de auditoría que lista start, end y trigger de todas las instancias de una página funciona igual en tu proyecto que en el de cualquier otro. Compáralo con el modelo mental habitual de los eventos del DOM, donde el estado real vive repartido entre el elemento, el evento y una docena de propiedades calculadas que hay que pedir una por una y que fuerzan reflows al pedirlas. Aquí el estado está precalculado, es plano y es el mismo para todos. Cuando diseñes tu propia capa de animación sobre esto, imita esa decisión: pasa el objeto entero, no un resumen. El coste es cero y lo que te ahorras es la mitad de las sesiones de depuración.

⚔️ Instrumentar el scroll
  1. Sustituye los cuatro callbacks de posición de un caso real por un único onToggle y comprueba que el comportamiento es idéntico.
  2. Provoca el fallo del onToggle que no se dispara: intervalo muy corto y un salto de scroll de una pantalla entera de golpe.
  3. Construye el efecto de inclinación por velocidad con gsap.to dentro de onUpdate y luego con gsap.quickTo. Compara ambos en el panel de rendimiento.
  4. Mueve un cálculo de anchura del cuerpo del módulo a un onRefresh y comprueba la diferencia al redimensionar.
  5. Añade labelToScroll a una timeline con etiquetas y construye un índice navegable de tres enlaces.