El modelo mental de ScrollTrigger
Por qué ScrollTrigger no vigila elementos sino que precalcula un intervalo de scroll, qué es exactamente el progress y cuáles son las dos maneras de consumirlo.
Casi todo el mundo aprende ScrollTrigger al revés: copia una configuración de un CodePen, la retoca hasta que algo se mueve, y a partir de ahí vive en un estado permanente de superstición en el que cambiar un valor produce efectos que no sabe explicar. El problema es que el modelo mental por defecto —“esto vigila un elemento y hace cosas cuando entra en pantalla”— es falso, y todos los comportamientos que parecen caprichosos son consecuencias perfectamente deducibles del modelo verdadero. ScrollTrigger no vigila nada: mide una vez, calcula dos números, y luego solo mira la barra de scroll.
- Explicar por qué ScrollTrigger convierte cada configuración en un intervalo numérico de píxeles de scroll.
- Distinguir el
progressde la posición del scroll y de la posición de la animación. - Diferenciar las dos formas de consumir el progress y por qué son excluyentes.
- Reconocer el ciclo de vida de una instancia y cuándo se recalculan sus medidas.
Un intervalo de scroll, no un observador
La primera pregunta que todo el mundo hace es si ScrollTrigger usa IntersectionObserver por debajo. La respuesta es que no, y el motivo explica el resto del diseño. IntersectionObserver está pensado para responder a la pregunta binaria de si un elemento se cruza con un rectángulo; no sabe decirte cuánto llevas recorrido entre dos puntos, que es justo lo que hace falta para enganchar una animación al scroll. Además obliga al navegador a vigilar cada elemento registrado de forma continua, y con doscientos elementos en una página larga eso se nota.
ScrollTrigger hace lo contrario. En el momento de crearse mide dónde está el elemento disparador en el flujo normal del documento y traduce tu configuración a dos números en píxeles: la posición de la barra de scroll en la que empieza y la posición en la que acaba. A partir de ahí, la instancia ya no sabe nada de elementos. Solo sabe que está activa cuando la barra de scroll está entre 1240 y 1980, por ejemplo. Escuchar el scroll es barato; comparar un número contra dos números es prácticamente gratis.
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const st = ScrollTrigger.create({
trigger: ".seccion",
start: "top center",
end: "bottom center",
});
// Los dos numeros que de verdad usa, en pixeles de scroll:
console.log(st.start, st.end);
De esa decisión salen tres consecuencias que conviene interiorizar ahora, porque son el origen del noventa por ciento de los problemas. La primera es que las medidas son estáticas: si animas el propio elemento disparador, o si cargas una imagen que empuja el contenido hacia abajo, los dos números siguen siendo los de antes y la animación se dispara en el sitio equivocado. La segunda es que el orden de creación importa cuando hay fijados de por medio, porque cada fijación alarga el documento y desplaza todo lo que viene después. La tercera es que hay que recalcular cuando el documento cambia de altura, y ScrollTrigger lo hace solo en el resize del scroller pero no puede adivinar que has insertado tres párrafos por JavaScript.
Además, ScrollTrigger no reacciona a cada evento de scroll individualmente. Los agrupa y actualiza en el siguiente requestAnimationFrame, sincronizado con el mismo tick que usa el motor de GSAP para todo lo demás. Por eso una animación de scroll y una animación de tiempo nunca van desfasadas medio frame entre sí.
Del scroll al progress
El progress es el valor normalizado entre 0 y 1 que indica cuánto del intervalo llevas recorrido. Es una simple regla de tres sobre los dos números anteriores, y es el único dato que ScrollTrigger produce. Todo lo demás —los callbacks, el scrub, el snap, las clases que se ponen y se quitan— son maneras distintas de consumir ese número.
flowchart TB a[Posicion de la barra de scroll en pixeles] --> b[Intervalo start end medido una sola vez] b --> c[progress normalizado entre 0 y 1] c --> d[Modo evento con toggleActions y callbacks] c --> e[Modo scrub con el playhead atado al progress] d --> f[La animacion corre con su propio reloj] e --> g[La animacion no tiene reloj propio] style b fill:#89b4fa,color:#11111b style c fill:#cba6f7,color:#11111b style f fill:#f9e2af,color:#11111b style g fill:#a6e3a1,color:#11111b
La bifurcación de abajo es la decisión de diseño más importante que vas a tomar en cada ScrollTrigger, y mucha gente la toma sin darse cuenta. En modo evento, el scroll solo sirve para dar la orden de arranque: la animación se reproduce después con su propia duración, su propio ease y su propio reloj, y si el usuario deja de hacer scroll la animación sigue. En modo scrub, la animación deja de tener reloj: su cabezal se coloca en la posición que dicta el progress, y si el usuario para, la animación para exactamente ahí, congelada a mitad.
Son excluyentes por naturaleza. Un ease: "elastic.out" en modo scrub no produce un rebote, produce una zona del scroll en la que el elemento se mueve rarísimo hacia adelante y hacia atrás según muevas la rueda. Y una duration en modo scrub deja de significar segundos: pasa a ser un peso proporcional dentro de la línea de tiempo. Esa reinterpretación es tan poco intuitiva que merece detallarse.
Si una timeline tiene tres tweens encadenados de 1, 3 y 1 segundos, la duración total es 5. Con scrub activado, el primer tween ocupará el primer quinto del recorrido de scroll, el segundo los tres quintos centrales y el tercero el último quinto. Los números absolutos no significan nada; solo cuentan las proporciones. Si quieres que la secuencia entera necesite más scroll, no toques las duraciones: alarga el intervalo con end.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".panel",
start: "top top",
end: "+=2000", // 2000px de scroll para toda la timeline
scrub: true,
},
});
tl.to(".titulo", { yPercent: -50, duration: 1 }) // primer 20%
.to(".fondo", { scale: 1.4, duration: 3 }) // 60% central
.to(".pie", { autoAlpha: 1, duration: 1 }); // ultimo 20%
Las dos formas de crear una instancia
Hay exactamente dos maneras de crear un ScrollTrigger, y no son intercambiables aunque lo parezcan. La primera es pasar el objeto scrollTrigger dentro de las vars de un tween o de una timeline. La segunda es ScrollTrigger.create(), que crea una instancia sin animación asociada.
// 1) Atado a una animacion: la instancia controla ese tween
gsap.from(".tarjeta", {
y: 40,
autoAlpha: 0,
duration: 0.6,
scrollTrigger: { trigger: ".tarjeta", start: "top 85%" },
});
// 2) Independiente: no controla ninguna animacion, solo avisa
ScrollTrigger.create({
trigger: ".seccion-oscura",
start: "top 50%",
end: "bottom 50%",
onToggle: (self) => {
document.body.classList.toggle("tema-oscuro", self.isActive);
},
});
La regla práctica es que una instancia controla como mucho una animación. Si necesitas coordinar varias, mételas todas en una timeline y ata la timeline; si de verdad son independientes, crea varias instancias. Intentar meter dos animaciones en un mismo ScrollTrigger es un error de modelo, no una limitación arbitraria.
La versión independiente es más útil de lo que parece. Cambiar un tema, cargar un vídeo cuando su sección se acerca, activar el enlace correspondiente de un índice lateral, disparar una métrica: todo eso son eventos de scroll que no necesitan animación ninguna, y usar ScrollTrigger para ellos te ahorra escribir la aritmética de posiciones a mano.
En una página con etiquetas script clásicas, los builds minificados de GSAP se autorregistran al cargarse después del core, y todo parece ir bien sin llamar a gsap.registerPlugin(). En cuanto pasas a un bundler, el tree shaking ve un módulo importado del que nadie llama a nada y lo elimina del build de producción. El síntoma clásico es “funciona en desarrollo y deja de funcionar al desplegar”. Registrar el plugin es lo que crea la referencia explícita que impide esa poda.
Refresh, kill y el resto del ciclo de vida
Como las medidas son estáticas, existe una operación explícita para rehacerlas: ScrollTrigger.refresh(). Se dispara sola cuando el scroller cambia de tamaño, aunque no de inmediato: ScrollTrigger espera a que haya un hueco de unos doscientos milisegundos sin eventos de resize antes de ponerse a medir, porque medirlo todo es caro y durante un arrastre de la ventana llegan decenas de eventos por segundo.
Lo que no se dispara solo es todo lo demás. Si insertas contenido, si abres un acordeón, si una fuente tarda en cargar y reflowea un titular, o si una imagen sin width y height declarados empuja media página hacia abajo, los intervalos se quedan obsoletos y la única pista es que las animaciones se disparan “un poco antes o un poco después de donde deberían”.
// Tras cualquier cambio que altere la altura del documento
document.querySelector(".acordeon").addEventListener("toggle", () => {
ScrollTrigger.refresh();
});
// Y para el caso mas comun de todos: imagenes sin dimensiones declaradas
window.addEventListener("load", () => ScrollTrigger.refresh());
Al otro extremo del ciclo está la destrucción. Una instancia mantiene escuchas de scroll y referencias a elementos del DOM; si el componente que la creó desaparece y nadie la mata, la instancia sigue viva, sigue midiendo contra nodos que ya no existen y sigue impidiendo que el recolector de basura libere ese subárbol. st.kill() destruye una instancia concreta, st.disable() la deja apagada pero recuperable, y ScrollTrigger.getAll() te devuelve la lista completa, que es la herramienta de diagnóstico cuando sospechas que estás acumulando instancias.
// Diagnostico rapido: cuantas instancias vivas hay ahora mismo
console.table(
ScrollTrigger.getAll().map((t) => ({
id: t.vars.id,
start: Math.round(t.start),
end: Math.round(t.end),
activa: t.isActive,
}))
);
Merece la pena detenerse en lo que ScrollTrigger decidió no hacer, porque ahí está la lección transferible. El planteamiento obvio para “animar según el scroll” es reactivo: vigila los elementos, calcula su posición en cada frame, compara con el viewport y actúa. Ese diseño es correcto, es fácil de explicar y es insostenible: cada elemento vigilado añade una lectura de geometría por frame, y leer geometría fuerza al navegador a recalcular el layout pendiente en ese preciso instante, con lo que doscientos elementos vigilados producen doscientos reflows sincronizados por frame en el peor caso. ScrollTrigger invierte la carga por completo: paga una vez, cara, en el momento de crearse y en cada refresh, a cambio de que el trabajo por frame sea aritmética entera. El coste no desaparece, se mueve a un instante en el que puedes permitírtelo. Esa es exactamente la jugada del ahead-of-time frente al just-in-time, y como toda compilación anticipada, viene con su factura: el resultado compilado se queda obsoleto si cambian las entradas, y por eso refresh() existe y por eso tienes que llamarlo tú cuando el cambio no es un resize. Cuando alguien te diga que ScrollTrigger es frágil porque hay que refrescarlo, lo que está describiendo es el precio de que sea rápido. La alternativa no es un ScrollTrigger que se refresque solo siempre; la alternativa es un ScrollTrigger que mida en cada frame y que haga inservible cualquier página con más de treinta secciones. Entendido así, refresh() deja de ser una molestia y pasa a ser lo que en realidad es: la operación de invalidación de una caché, y las cachés no se invalidan solas.
- Crea un
ScrollTrigger.create()sobre una sección cualquiera y saca por consolastartyend. Comprueba que coinciden con las posiciones reales de la barra de scroll. - Inserta por JavaScript un bloque de 800px de alto por encima de esa sección y vuelve a leer
start. Explica por qué no ha cambiado. - Llama a
ScrollTrigger.refresh()y vuelve a leerlo. - Monta la misma timeline de tres tweens del ejemplo con y sin
scruby describe qué significadurationen cada caso. - Deja abierta la consola con
ScrollTrigger.getAll().lengthy navega por tu aplicación. Si el número solo sube, ya tienes una fuga.