wandres.dev
GSAP III · Timelines

El problema de sumar delays a mano

Por qué la secuenciación con retardos calculados no escala, qué es exactamente una timeline, y cómo los defaults heredados eliminan la repetición de una coreografía.

⏱ 17 min

Toda animación compleja empieza igual: dos pasos encadenados con un retardo. Funciona. Luego son cinco, y el retardo del quinto es una suma de cuatro números. Luego el diseñador pide que el tercero dure el doble y hay que recalcular tres retardos. Luego alguien pide que se pueda rebobinar y descubres que no hay nada que rebobinar, solo cinco animaciones sueltas que casualmente empiezan en momentos distintos. Ese es el problema que resuelve una timeline, y merece la pena verlo con claridad antes de aprender la sintaxis, porque la sintaxis es trivial y el cambio de modelo no.

🎯 Al terminar esta lección sabrás
  • Enunciar por qué la secuenciación con retardos no escala y qué tres cosas rompe.
  • Construir una timeline encadenando métodos y explicar qué devuelve cada uno.
  • Usar defaults para no repetir duración y ease en cada paso.
  • Distinguir lo que hereda un hijo de lo que declara por su cuenta.

Tres cosas que rompe la aritmética de retardos

Aquí está la misma coreografía escrita con retardos:

gsap.to('.logo',    { autoAlpha: 1, duration: 0.6, ease: 'power2.out' });
gsap.to('.titular', { y: 0, autoAlpha: 1, duration: 0.8, delay: 0.4, ease: 'power2.out' });
gsap.to('.texto',   { y: 0, autoAlpha: 1, duration: 0.8, delay: 1.0, ease: 'power2.out' });
gsap.to('.boton',   { scale: 1, autoAlpha: 1, duration: 0.5, delay: 1.6, ease: 'power2.out' });

Se lee bien y funciona. Rompe tres cosas.

Rompe el mantenimiento. Los números 0.4, 1.0 y 1.6 no dicen nada; son el resultado de una suma que ocurrió en la cabeza de quien lo escribió. La intención era “el titular entra cuando el logo lleva dos tercios” y esa intención no está en el código. Cambiar la duración del logo a 0.9 exige recalcular los tres, y nadie recuerda por qué el segundo era 1.0 y no 1.2.

Rompe el control. No hay ningún objeto que represente “la animación de entrada”. Hay cuatro tweens independientes. Pausarla es guardar los cuatro en un array y recorrerlo. Rebobinarla es imposible en cualquier sentido útil, porque cada tween se invertiría desde su propio punto y los retardos no se invierten.

Rompe la composición. No puedes meter esta secuencia dentro de otra mayor, ni reutilizarla desplazada en el tiempo, ni disparar una segunda copia con parámetros distintos.

Qué es una timeline

Una timeline es un contenedor de animaciones con su propio cabezal de lectura. Cuando su cabezal se mueve, coloca a cada hijo en la posición que le corresponde. Un hijo puede ser un tween o, recursivamente, otra timeline.

const tl = gsap.timeline();

tl.to('.logo',    { autoAlpha: 1, duration: 0.6 })
  .to('.titular', { y: 0, autoAlpha: 1, duration: 0.8 })
  .to('.texto',   { y: 0, autoAlpha: 1, duration: 0.8 })
  .to('.boton',   { scale: 1, autoAlpha: 1, duration: 0.5 });

Sin ningún número de posición, cada paso se coloca al final de lo que hay, así que la secuencia es estricta. Y ahora sí hay un objeto:

tl.pause();
tl.reverse();
tl.seek(1.2);
tl.timeScale(0.25);
tl.progress(0.5);

Los métodos to, from, fromTo, set, call, add y addLabel de una timeline devuelven la propia timeline, por eso se encadenan. Fíjate en la diferencia con gsap.to, que devuelve un tween: tl.to(...) devuelve tl. Si necesitas la referencia al tween que acabas de añadir, hay dos caminos: crearlo aparte y añadirlo con add, o recuperarlo con tl.recent(), que devuelve el último hijo añadido.

const paso = gsap.to('.logo', { autoAlpha: 1, duration: 0.6 });
tl.add(paso);

Cambiar la duración de un paso ahora no exige tocar nada más: todo lo que venía detrás se desplaza solo, porque las posiciones son relativas por construcción y no números calculados.

ℹ️
Una timeline se reproduce sola

Igual que un tween, una timeline empieza a reproducirse en cuanto se crea, y va añadiendo hijos mientras el cabezal avanza. Eso suele estar bien porque la construcción ocurre en la misma tarea síncrona, antes del primer tick. Pero si construyes una timeline en varios pasos separados por await o por callbacks, los primeros hijos ya estarán corriendo cuando añadas los últimos. Para esos casos, gsap.timeline({ paused: true }) y un play() explícito al terminar de construir.

Los defaults heredados

En la versión con retardos, ease: 'power2.out' aparecía cuatro veces. En una coreografía de veinte pasos aparecería veinte, y cambiar la sensación del conjunto sería veinte ediciones con la garantía de que una se queda sin cambiar.

El objeto defaults de una timeline se inyecta en todos los hijos que se creen a partir de ella:

const tl = gsap.timeline({
  defaults: { duration: 0.8, ease: 'power2.out' },
});

tl.to('.logo',    { autoAlpha: 1, duration: 0.6 })   // sobrescribe la duracion
  .to('.titular', { y: 0, autoAlpha: 1 })            // hereda 0.8 y power2.out
  .to('.texto',   { y: 0, autoAlpha: 1 })
  .to('.boton',   { scale: 1, autoAlpha: 1, ease: 'back.out(1.6)' });  // su propio ease

No está limitado a duration y ease: cualquier propiedad vale, incluidas las que se animan. Un defaults: { autoAlpha: 1 } haría que todos los hijos animaran la opacidad a uno sin declararlo, aunque eso suele ser más confuso que útil. Lo sensato es reservar defaults para las propiedades de tiempo y sensación.

Lo que un hijo declara explícitamente gana siempre. Y si un hijo no quiere heredar nada, inherit: false lo desconecta:

tl.to('.especial', { x: 100, inherit: false });   // ni duracion ni ease heredados

La herencia funciona en cascada por anidamiento: una timeline hija hereda los defaults de su padre, y los suyos propios ganan sobre los del padre. Con tres niveles de anidamiento eso puede volverse difícil de seguir, así que la disciplina razonable es declarar los defaults en un solo nivel —típicamente la timeline maestra— y dejar las hijas sin ellos.

Convertir la coreografía en algo reutilizable

El paso que hace que todo esto valga la pena es envolver la construcción en una función que devuelve la timeline:

function entradaDeCabecera(raiz) {
  const q = gsap.utils.selector(raiz);
  const tl = gsap.timeline({ defaults: { duration: 0.8, ease: 'power2.out' } });

  tl.from(q('.logo'),    { autoAlpha: 0, duration: 0.6 })
    .from(q('.titular'), { y: 40, autoAlpha: 0 })
    .from(q('.texto'),   { y: 24, autoAlpha: 0 })
    .from(q('.boton'),   { scale: 0.8, autoAlpha: 0, ease: 'back.out(1.6)' });

  return tl;
}

Esa función se puede llamar con distintas raíces, se puede meter dentro de otra timeline, y se puede guardar pausada para dispararla cuando toque. Es el patrón que sostiene cualquier animación de tamaño medio, y todo lo que viene después en este nivel —el parámetro de posición, las etiquetas, el anidamiento— se apoya en él.

La duración de una timeline es un resultado, no un dato

Una timeline no tiene duración propia: su duración es la posición final de su hijo más lejano, calculada sobre la marcha. Añadir un hijo la alarga, quitarlo la acorta, y cambiar la duración de cualquier hijo la recalcula. Eso es lo que hace que la secuenciación funcione, y tiene dos consecuencias que sorprenden.

La primera: tl.duration(3) no recorta ni estira los hijos. Lo que hace es ajustar el timeScale de la timeline para que el conjunto quepa en tres segundos, manteniendo las proporciones. Es un atajo muy útil —“que toda la coreografía dure tres segundos, repártelo tú”— y no lo que la gente espera la primera vez.

La segunda: un hijo colocado en una posición absoluta lejana define la duración aunque no haya nada entre medias. Un tl.to(x, {...}, 20) en una timeline de dos segundos la convierte en una timeline de veinte con dieciocho segundos de nada. Es la causa habitual de “mi timeline tarda una eternidad en acabar”: un número de posición que era relativo y se escribió absoluto.

⚔️ Reto práctico

Coge la versión con retardos del principio y conviértela en una timeline con defaults. Después cambia la duración del primer paso de 0.6 a 1.4 en las dos versiones y comprueba que en una hay que tocar tres números más y en la otra ninguno. Finalmente añade a la versión con timeline un botón que la invierta, y comprueba que en la versión con retardos ese botón no se puede escribir.