wandres.dev
GSAP V · Stagger y utilidades

El stagger simple

Qué construye GSAP cuando escribes un número en stagger, por qué un solo tween se convierte en varios subtweens, y qué implica eso para el control y para la duración total.

⏱ 16 min

stagger: 0.1 es probablemente la propiedad con mejor relación entre caracteres escritos y efecto conseguido de toda la librería: convierte una animación plana en una cascada que se lee como intencionada. Lo que hace por dentro es menos evidente de lo que parece, y saberlo importa porque cambia cómo se calcula la duración total, qué significa onComplete, y por qué poner repeat dentro o fuera del objeto de stagger produce cosas completamente distintas.

🎯 Al terminar esta lección sabrás
  • Predecir la duración total de un tween con stagger.
  • Explicar qué construye GSAP internamente al escribir un stagger.
  • Usar valores negativos y saber qué invierte exactamente.
  • Distinguir cuándo un stagger sustituye a un bucle y cuándo no.

Un número y una cascada

Cuando un tween tiene varios objetivos, stagger introduce un desfase entre los instantes de inicio de cada uno.

gsap.to('.caja', {
  y: 100,
  duration: 0.5,
  stagger: 0.1,   // 0.1 s entre el arranque de cada .caja
});

Con diez cajas, la primera arranca en el instante cero, la segunda en 0,1, la décima en 0,9. Y como cada una dura medio segundo, la última termina en 1,4 segundos.

Esa aritmética es la primera cosa que hay que interiorizar: la duración total no es duration. Es duration + stagger × (n - 1). Con muchos elementos, el desfase domina: cien cajas con stagger: 0.1 tardan 10,4 segundos aunque cada una dure medio.

Esa es la razón por la que existe la opción amount, que veremos en la lección siguiente y que invierte el planteamiento: en vez de fijar el hueco entre elementos, fija el total que se reparte entre ellos.

Un valor negativo hace lo mismo pero empezando por el final: el último elemento arranca primero. No invierte la animación de cada elemento, solo el orden de arranque.

gsap.to('.caja', { y: 100, duration: 0.5, stagger: -0.1 });

Qué construye GSAP por dentro

Aquí está la parte que explica todo lo demás. Cuando un tween tiene stagger, GSAP no crea un solo tween que escribe en todos los objetivos: crea una estructura interna con un subtween por objetivo, cada uno con su propio instante de inicio.

Eso tiene cuatro consecuencias prácticas.

El objeto que recibes sigue siendo uno. gsap.to(...) con stagger devuelve un solo objeto controlable. Pausarlo, invertirlo o escalar su velocidad afecta al conjunto. Esa es la ventaja principal frente a un bucle que crea diez tweens.

Los callbacks del nivel superior son del conjunto. onComplete se dispara cuando ha terminado el último, no cuando termina cada uno. onStart se dispara cuando arranca el primero.

Los callbacks dentro del objeto de stagger son por elemento. Es el mecanismo para reaccionar a cada uno, y está detallado en la lección del objeto de stagger.

Las propiedades de repetición se comportan distinto según dónde estén. Un repeat en el nivel superior del vars espera a que todos los subtweens terminen antes de repetir la secuencia completa. Un repeat dentro del objeto de stagger hace que cada subtween se repita por su cuenta en cuanto acaba, sin esperar a nadie.

// Repite la cascada entera cuando todos han acabado.
gsap.to('.caja', { y: 100, duration: 0.5, stagger: 0.1, repeat: -1 });

// Cada caja repite por su cuenta, manteniendo el desfase inicial.
gsap.to('.caja', { y: 100, duration: 0.5, stagger: { each: 0.1, repeat: -1 } });

Los dos se ven completamente distintos y los dos se escriben casi igual. El primero produce oleadas separadas; el segundo, un movimiento continuo desfasado, que es lo que quieres para un patrón ondulatorio permanente.

ℹ️
El orden es el del array de objetivos

El desfase se aplica según la posición en el array de objetivos, y ese array viene de document.querySelectorAll, que devuelve los elementos en orden de documento. No en orden visual. En una rejilla con flex-direction: row-reverse o con order de CSS, el stagger irá en el orden del marcado y no en el que se ve en pantalla, lo cual desconcierta bastante. Para basar el desfase en la posición real hay que usar la opción grid o una función de stagger.

Stagger frente a bucle

La alternativa a un stagger es crear un tween por elemento con retardos calculados:

// La version manual: NO hagas esto salvo que necesites tweens distintos.
gsap.utils.toArray('.caja').forEach((el, i) => {
  gsap.to(el, { y: 100, duration: 0.5, delay: i * 0.1 });
});

Funciona y produce el mismo resultado visual, y es peor por tres razones concretas.

La primera es el control: diez tweens hay que guardarlos y recorrerlos para pausar o invertir. El stagger da un objeto.

La segunda es el conocimiento del final: saber cuándo han acabado todos exige contar. El stagger tiene onComplete.

La tercera, y la que más sorprende, es la precisión temporal. Diez tweens creados en un bucle son diez animaciones independientes en la timeline global, cada una con su propio retardo; un stagger es una estructura interna con instantes calculados de una vez. En la práctica ambos van sincronizados porque el motor tiene un reloj único, pero cualquier cosa que interfiera con la creación —una tarea larga entre iteraciones, una lista muy grande— desplaza los retardos del bucle y no los del stagger.

El bucle sí gana en un caso: cuando cada elemento necesita un tween estructuralmente distinto, no solo valores distintos. Si el elemento tres tiene que animar otra propiedad, o tener otra duración calculada de forma no expresable como función del índice, el stagger no lo cubre. Para valores distintos por elemento, las funciones como valor resuelven el caso sin salir de un solo tween.

Stagger en una timeline

El stagger funciona igual dentro de una timeline, y ahí hay un detalle de posicionamiento que importa: el parámetro de posición coloca el inicio del conjunto, y los punteros "<" y ">" de la animación siguiente se refieren al conjunto entero.

const tl = gsap.timeline();

tl.from('.titulo', { y: 30, autoAlpha: 0, duration: 0.5 })
  .from('.item', { y: 20, autoAlpha: 0, duration: 0.4, stagger: 0.08 }, '-=0.2')
  .from('.pie', { autoAlpha: 0, duration: 0.4 }, '>-0.15');

El ">" del último paso apunta al final del último elemento escalonado, no al final del primero. Es lo que se espera, y conviene tenerlo presente cuando la lista es larga: con cincuenta elementos y stagger: 0.08, el conjunto dura cuatro segundos y ese ">" está muy lejos.

Ahí es donde amount deja de ser una comodidad y pasa a ser necesario: fijar el total hace que la coreografía siga funcionando con tres elementos y con trescientos.

El stagger que mata el rendimiento no es el que crees

La intuición dice que animar doscientos elementos a la vez es caro y que escalonarlos lo reparte. Es exactamente al revés en el aspecto que importa.

Un stagger no reduce el número de elementos animándose simultáneamente: con duration: 0.5 y stagger: 0.05, hay diez elementos activos en todo momento en régimen estacionario, y con stagger: 0.005 hay cien. Lo que sí hace es alargar la ventana de tiempo durante la cual el navegador tiene que mantener capas de composición, y eso es lo caro. Doscientos elementos animando transform a la vez durante medio segundo son doscientas capas durante medio segundo; los mismos doscientos escalonados a 0,05 son doscientas capas durante diez segundos y medio, porque las primeras siguen teniendo will-change implícito mientras las últimas aún no han empezado.

El síntoma es un consumo de memoria de GPU que crece con listas largas y una caída de fotogramas que empeora cuanto más lento es el escalonado, que es lo contrario de lo que todo el mundo espera. Las dos mitigaciones que funcionan: usar amount en vez de each para que el total no crezca con el número de elementos, y no animar lo que no está en pantalla —lo cual, en una lista larga, significa disparar el stagger por secciones a medida que entran en el viewport en lugar de todo de una vez.

⚔️ Reto práctico

Comprueba la aritmética: anima veinte elementos con duration: 0.5 y stagger: 0.1, y registra el instante en que se dispara onComplete. Debe ser 2,4 segundos, no 0,5. Después cambia a stagger: -0.1 y verifica con un onStart dentro del objeto de stagger que el orden de arranque se ha invertido pero que la duración total es la misma.