Timelines anidadas
Descomponer una coreografía grande en funciones que devuelven timelines, ensamblarlas en una maestra, y entender cómo se propaga el tiempo entre niveles de anidamiento.
Una timeline puede contener otras timelines, y esa recursión es lo que hace que el modelo escale de cinco pasos a doscientos sin volverse ilegible. La técnica es la misma que con cualquier código: descomponer en unidades con sentido propio y componerlas. Lo que hay que entender bien es cómo viaja el tiempo entre niveles, porque una timeline hija no tiene un reloj propio conectado al mundo: tiene un cabezal que su padre mueve, y su timeScale se multiplica por el del padre, y eso tiene consecuencias que sorprenden la primera vez.
- Descomponer una coreografía en funciones que devuelven timelines.
- Ensamblar timelines hijas en una maestra con el parámetro de posición.
- Explicar cómo se propaga el tiempo y cómo se multiplican las escalas temporales.
- Inspeccionar la estructura de una timeline con sus métodos de consulta.
El patrón de composición
La unidad de descomposición es la función que construye y devuelve una timeline:
function intro(q) {
const tl = gsap.timeline();
tl.from(q('.velo'), { autoAlpha: 1, duration: 0.9 })
.from(q('.logo'), { scale: 0.8, autoAlpha: 0, duration: 0.6 }, '-=0.3');
return tl;
}
function cuerpo(q) {
const tl = gsap.timeline();
tl.from(q('.titular'), { y: 40, autoAlpha: 0, duration: 0.7 })
.from(q('.parrafo'), { y: 24, autoAlpha: 0, duration: 0.7 }, '<0.15')
.from(q('.imagen'), { scale: 1.1, autoAlpha: 0, duration: 1 }, '<');
return tl;
}
function cierre(q) {
const tl = gsap.timeline();
tl.from(q('.boton'), { scale: 0.85, autoAlpha: 0, duration: 0.5, ease: 'back.out(1.7)' })
.from(q('.nota'), { autoAlpha: 0, duration: 0.4 }, '<0.1');
return tl;
}
export function construir(raiz) {
const q = gsap.utils.selector(raiz);
const maestra = gsap.timeline({ defaults: { ease: 'power2.out' } });
maestra
.add(intro(q))
.add(cuerpo(q), '-=0.2')
.add(cierre(q), '+=0.3');
return maestra;
}
Tres funciones de cinco líneas y un ensamblaje de cuatro. Cada pieza se puede probar aislada, reordenar, reutilizar y sustituir. La maestra no sabe nada del interior de cada una: solo cuándo empieza.
add() es el método que acepta cualquier hijo —un tween, una timeline, una etiqueta, un callback, o un array de ellos— más el parámetro de posición. Y como los métodos de conveniencia to, from y fromTo de una timeline crean un tween y lo añaden, add(gsap.to(...)) y tl.to(...) son equivalentes salvo por lo que devuelven.
Hay un atajo cómodo: una timeline hija también se puede insertar directamente en la llamada de construcción de la maestra pasándola como argumento, pero el patrón con add es más legible cuando hay parámetros de posición de por medio.
Cómo viaja el tiempo
El modelo es simple y conviene enunciarlo con precisión. Cada animación, tween o timeline, está colocada en una timeline padre, y todo cuelga en última instancia de gsap.globalTimeline. Cuando el cabezal de un padre se mueve a un instante nuevo, recorre sus hijos y a cada uno le dice: renderízate como si tu cabezal estuviera en tal posición. Si ese hijo es una timeline, hace lo mismo con los suyos, recursivamente.
De ahí salen tres consecuencias.
Los cabezales están siempre sincronizados. No hay forma de que un hijo se desincronice del padre, porque no tiene un reloj propio: recibe su posición en cada actualización. Esa es la garantía que ninguna colección de animaciones independientes puede dar.
Las escalas temporales se multiplican. Si la maestra tiene timeScale(0.5) y una hija tiene timeScale(2), esa hija corre a velocidad normal respecto al mundo, y las hermanas que no tocaron su escala corren a la mitad. Es composición, no sustitución.
maestra.timeScale(0.5);
// La hija corre a 0.5 * 2 = 1x respecto al mundo.
hija.timeScale(2);
Un hijo pausado se queda quieto mientras el padre avanza. Pausar un hijo lo desconecta de las actualizaciones de posición. Y al reanudarlo, GSAP recoloca su instante de inicio para que continúe suavemente desde donde el padre está, en lugar de saltar. Ese comportamiento lo controla smoothChildTiming, que por defecto es false en las timelines que creas y true en la global.
smoothChildTiming merece una explicación porque produce el efecto más raro del modelo. Con el valor false, invertir un hijo lo invierte en el sitio: su instante de inicio en el padre no cambia, así que un hijo que iba por el 75% pasa a estar visto desde el padre como si estuviera por el 25%. Con true, GSAP mueve su instante de inicio para que la reproducción sea continua. La mayoría de las veces no notarás la diferencia; cuando animes el timeScale de una hija o inviertas hijos individuales sí, y entonces smoothChildTiming: true en el padre es lo que arregla los saltos.
Igual que con los tweens, la duración de una timeline anidada es un resultado. La maestra mide lo que mida su hija que acabe más tarde. Añadir un paso al final de una hija alarga la maestra, y eso a su vez desplaza cualquier cosa que la maestra tuviera colocada con posiciones relativas al final. Es el comportamiento correcto y es por lo que las posiciones relativas escalan: la información fluye hacia arriba sola.
Reutilizar la misma hija con parámetros
Como la construcción está en una función, se puede parametrizar. Es la forma de tener una animación tipo y aplicarla a variantes:
function revelar(elementos, { desde = 30, escalonado = 0.08 } = {}) {
const tl = gsap.timeline();
tl.from(elementos, {
y: desde,
autoAlpha: 0,
duration: 0.6,
stagger: escalonado,
ease: 'power2.out',
});
return tl;
}
const maestra = gsap.timeline();
maestra
.add(revelar(q('.fila-1 .celda')))
.add(revelar(q('.fila-2 .celda'), { desde: 50 }), '<0.2')
.add(revelar(q('.fila-3 .celda'), { escalonado: 0.15 }), '<0.2');
Un matiz que ahorra un bug: la función crea la timeline y la timeline empieza a reproducirse en cuanto se crea, antes de que la añadas a la maestra. En la práctica no importa porque todo ocurre en la misma tarea síncrona y el primer tick llega después. Pero si construyes de forma asíncrona, crea las hijas con paused: true; al añadirlas a una maestra, su reproducción pasa a estar gobernada por ella de todos modos.
Inspeccionar la estructura
Cuatro métodos que valen su peso en depuración:
// Todos los hijos directos.
maestra.getChildren(false);
// Todos los descendientes, recursivamente, incluidos tweens y timelines.
maestra.getChildren(true);
// Solo los tweens, sin las timelines.
maestra.getChildren(true, true, false);
// Los hijos que afectan a un objetivo concreto.
maestra.getTweensOf('.titular', true);
getChildren(nested, tweens, timelines, ignoreBeforeTime) acepta cuatro argumentos y por defecto devuelve todo lo anidado. Combinado con startTime() y duration() de cada hijo, permite imprimir el mapa completo de una coreografía:
maestra.getChildren(true, true, true).forEach((h) => {
const tipo = h.getChildren ? 'timeline' : 'tween';
console.log(tipo, h.startTime().toFixed(2), h.duration().toFixed(2), h.vars.id ?? '');
});
Ese fragmento es la herramienta de diagnóstico que hay que tener a mano cuando una animación “sale en el momento equivocado”: te dice exactamente dónde está cada pieza. Ponerle un id a los hijos que importan hace la salida legible, y además permite recuperarlos con maestra.getById('nombre').
Y hay dos métodos de limpieza que conviene conocer juntos: remove(hijo) saca un hijo de la timeline sin matarlo, y killTweensOf(objetivos, propiedades) mata dentro de la timeline los tweens que afecten a esos objetivos, opcionalmente solo ciertas propiedades.
La razón por la que se anida no es solo la legibilidad. Es que cada nivel de anidamiento es una unidad de control independiente, y eso resuelve problemas que de otro modo son muy incómodos.
El caso canónico: una escena con una animación de fondo continua y una coreografía de contenido. Quieres poder ralentizar el contenido para depurarlo sin tocar el fondo, o pausar el contenido cuando el usuario interactúa dejando el fondo vivo. Con todo en una timeline plana eso no se puede: timeScale afecta a todo. Con dos timelines hijas dentro de una maestra, cada una tiene su propio control y la maestra sigue gobernando el conjunto.
El caso menos obvio: el bucle infinito dentro de una secuencia finita. Una hija con repeat: -1 dentro de una maestra hace que la maestra tenga duración infinita, con lo que su progress() deja de tener sentido y onComplete no se dispara nunca. La solución no es quitar el bucle sino sacarlo de la maestra: el bucle es una animación independiente que arranca desde un callback de la maestra, o una hija de una segunda timeline paralela. Si alguna vez has visto una timeline cuyo progress() siempre vale cero, busca un repeat: -1 entre sus descendientes.
Coge una coreografía plana de al menos diez pasos y refactorízala en tres timelines hijas ensambladas en una maestra, sin que el resultado visual cambie. Verifica la equivalencia comparando duration() y la salida del volcado de getChildren antes y después. Después demuestra lo que has ganado ralentizando una sola de las tres con timeScale.