wandres.dev
GSAP II · Los tweens: to, from, fromTo, set

Valores relativos, aleatorios y funciones como valor

Los cuatro tipos de valor dinámico que acepta un tween, cuándo se evalúan, y por qué una función por objetivo elimina la mayoría de los bucles que la gente escribe alrededor de GSAP.

⏱ 18 min

Un valor de tween no tiene que ser un número. Puede ser una expresión relativa al valor actual, una petición de número aleatorio, o una función que GSAP llama una vez por objetivo pasándole el índice y el elemento. Esas tres capacidades juntas cubren casi todo lo que en otras librerías obliga a escribir un bucle alrededor de la animación, y lo hacen manteniendo los objetivos dentro de un único tween, con lo que el control, el stagger y la sobrescritura siguen funcionando como un conjunto.

🎯 Al terminar esta lección sabrás
  • Escribir valores relativos y saber respecto a qué son relativos.
  • Usar la sintaxis de aleatorio con rango, con incremento y con lista de opciones.
  • Escribir funciones como valor y usar sus tres parámetros.
  • Determinar cuándo se evalúa cada tipo de valor y forzar su recálculo.

Valores relativos

Un prefijo "+=" o "-=" dentro de una cadena hace el valor relativo al valor que la propiedad tenga cuando el tween arranque por primera vez, no al valor que tuviera al escribir el código.

// Muevelo 20 pixeles mas a la derecha de donde este.
gsap.to('.caja', { x: '+=20', duration: 0.3 });

// Suma media vuelta a la rotacion actual.
gsap.to('.rueda', { rotation: '+=180', duration: 0.6 });

La distinción entre “cuando arranque” y “cuando se escriba” es la que importa. Si creas el tween pausado y lo reproduces medio segundo después, el valor de referencia es el de ese medio segundo después. Si el tween está dentro de una timeline, es el del momento en que el cabezal llega a él.

Eso significa que dos tweens relativos encadenados se acumulan de forma natural: dos "+=20" seguidos dejan el elemento 40 píxeles más allá. Y significa que un tween relativo dentro de una timeline que se rebobina y se vuelve a reproducir no vuelve a leer: los valores se registran en el primer renderizado y se conservan, que es lo que hace que rebobinar sea coherente. Para forzar que se relean en cada repetición está repeatRefresh.

// Cada vuelta suma 180 grados a la posicion en la que quedo.
gsap.to('.rueda', {
  rotation: '+=180',
  duration: 0.6,
  repeat: -1,
  repeatRefresh: true,
});

Sin repeatRefresh, ese tween haría la misma media vuelta una y otra vez, volviendo al principio en cada iteración. Con él, encadena vueltas.

Los valores relativos funcionan también con unidades: "+=10%", "-=2rem". Y con las rotaciones se combinan con los sufijos de dirección, de modo que "-=30_cw" es “treinta grados menos, dando la vuelta en sentido horario”.

Valores aleatorios

GSAP entiende una sintaxis de aleatorio dentro de una cadena, y la resuelve una vez por objetivo, que es lo que la hace útil.

// Un numero aleatorio distinto para cada elemento, entre -100 y 100.
gsap.to('.particula', { x: 'random(-100, 100)', duration: 1 });

// Con incremento: solo multiplos de 5.
gsap.to('.particula', { x: 'random(-100, 100, 5)', duration: 1 });

// Elegir de una lista.
gsap.to('.particula', { backgroundColor: 'random([#f38ba8, #a6e3a1, #89b4fa])', duration: 1 });

El tercer argumento del rango es el incremento al que se redondea el resultado, y es más útil de lo que parece: colocar elementos en posiciones aleatorias pero alineadas a una rejilla de ocho píxeles es una petición de diseño frecuente y cuesta un número.

Igual que con los relativos, el valor aleatorio se resuelve en el primer renderizado y se conserva. Un tween con repeat: -1 y valores aleatorios repite el mismo aleatorio para siempre; con repeatRefresh: true sortea de nuevo en cada iteración completa. Esa combinación es la receta del efecto de partículas que nunca se repite:

gsap.to('.chispa', {
  x: 'random(-200, 200)',
  y: 'random(-200, 200)',
  opacity: 0,
  duration: 'random(0.8, 1.6)',
  repeat: -1,
  repeatRefresh: true,
  ease: 'power2.out',
});

Cuidado con un detalle: repeatRefresh no refresca duration, delay ni stagger. En el ejemplo anterior, la duración aleatoria se sortea una vez y se queda fija para todas las repeticiones de ese elemento; lo que cambia en cada vuelta son x e y.

También existe la utilidad gsap.utils.random para obtener el número directamente en tu código, fuera de un tween.

Funciones como valor

Cualquier valor puede ser una función. GSAP la llama una vez por objetivo, en el primer renderizado, y usa lo que devuelva.

gsap.to('.item', {
  y: (index, target, targets) => index * 40,
  opacity: (index, target, targets) => 1 - index / targets.length,
  duration: 0.8,
});

Los tres parámetros son el índice del objetivo en la lista, el objetivo en sí, y el array completo de objetivos. Con ellos puedes:

Leer datos del propio elemento, que es la forma limpia de parametrizar desde el marcado:

gsap.to('.capa', {
  y: (i, el) => -100 * Number(el.dataset.profundidad),
  duration: 1.2,
  ease: 'none',
});

Calcular respecto al total, que permite repartos que dependen de cuántos hay:

gsap.to('.rayo', {
  rotation: (i, el, todos) => (360 / todos.length) * i,
  duration: 0.9,
});

Aplicar lógica condicional por elemento sin salir del tween:

gsap.to('.tarjeta', {
  x: (i, el) => (el.classList.contains('destacada') ? 0 : 60),
  autoAlpha: (i, el) => (el.classList.contains('oculta') ? 0 : 1),
  duration: 0.5,
});

La alternativa a las funciones como valor es un forEach que crea un tween por elemento, y la diferencia no es de estilo. Un solo tween con función es un solo objeto: se pausa, se invierte y se mata como una unidad, y su onComplete se dispara cuando todos han terminado. Cien tweens creados en un bucle son cien objetos que hay que guardar en un array, recorrer para pausar, y cuyo “han acabado todos” hay que calcular a mano. Cuando el reparto se pueda expresar como función del índice, un solo tween es casi siempre la respuesta correcta.

💡
Combinar función y utilidades

Las utilidades de gsap.utils están pensadas para vivir dentro de estas funciones. gsap.utils.mapRange para convertir un índice a un rango, gsap.utils.wrap para ciclar sobre una lista de valores, gsap.utils.distribute para repartir según posición en una rejilla. Muchas de ellas devuelven una función cuando se las llama sin el último argumento, precisamente para poder enchufarlas directamente como valor.

// wrap devuelve una funcion; GSAP la llama por objetivo con el indice.
gsap.to('.punto', {
  backgroundColor: gsap.utils.wrap(['#f38ba8', '#a6e3a1', '#89b4fa']),
  duration: 0.6,
});

Cuándo se evalúa cada cosa

Los tres tipos de valor dinámico comparten el mismo momento de evaluación: el primer renderizado del tween, no su creación. Y comparten el mismo mecanismo de invalidación.

Situación ¿Se reevalúa?
El tween se crea No; se evalúa en el primer renderizado
El tween se reproduce por segunda vez con restart() No
El tween se repite con repeat Solo si repeatRefresh: true
Se llama a tween.invalidate() Sí, en el siguiente renderizado
La timeline padre se rebobina y vuelve No

invalidate() es la herramienta explícita: borra los valores registrados internamente y obliga a releerlos. Es lo que necesitas cuando algo externo ha cambiado el estado y quieres que la animación parta del nuevo.

// Tras un cambio de tamano que movio los elementos.
tl.invalidate().restart();

Ese par —invalidar y reiniciar— es el patrón estándar para rehacer una animación tras un cambio de layout, y es más barato y más correcto que destruir la timeline y volver a construirla.

La función se llama una vez por objetivo, y ahí caben cosas caras

Como GSAP invoca la función una sola vez por objetivo y no en cada frame, dentro puede ir lógica que sería impensable en un onUpdate: leer el rectángulo del elemento, consultar estilos calculados, hacer trigonometría. Es el sitio correcto para medir.

// Cada elemento vuela hacia el centro del contenedor.
// getBoundingClientRect se llama una vez por elemento, no por frame.
const caja = contenedor.getBoundingClientRect();
gsap.to('.pieza', {
  x: (i, el) => caja.left + caja.width / 2 - el.getBoundingClientRect().left,
  y: (i, el) => caja.top + caja.height / 2 - el.getBoundingClientRect().top,
  duration: 0.8,
  ease: 'power3.inOut',
});

Ojo con un matiz que sí cuesta: si la función mide y GSAP ya ha escrito valores en objetivos anteriores de la misma lista, la medición del elemento número cincuenta ocurre con los cuarenta y nueve anteriores ya modificados. Para mediciones que dependan del estado original de todos, mide antes en un bucle propio, guarda los resultados en un array, y que la función solo lea de ese array por índice. Es la diferencia entre una medición correcta y una que depende del orden.

⚔️ Reto práctico

Reescribe sin bucle una animación que ahora mismo tengas con forEach: convierte cada valor que dependa del elemento en una función de tres parámetros y quédate con un solo tween. Después comprueba lo que has ganado llamando a tween.reverse() sobre el conjunto entero, algo que con el bucle exigía recorrer el array.