wandres.dev
GSAP III · Timelines

El control global: play, pause, reverse, seek, timeScale y progress

Los métodos que convierten una coreografía en algo controlable, la diferencia entre tiempo y tiempo total, y los patrones de interacción que solo son posibles con este control.

⏱ 19 min

Una animación que solo se puede disparar es un vídeo. Una animación que se puede pausar, invertir, escalar en velocidad y colocar en cualquier punto es un objeto de la aplicación, y con ella se pueden construir interacciones que de otro modo exigen reescribir la coreografía entera para cada dirección. Los métodos de control son pocos y su sintaxis es trivial; lo que tiene sustancia es la distinción entre tiempo y tiempo total, y el conjunto de patrones que solo aparecen cuando das por hecho que una animación es reversible.

🎯 Al terminar esta lección sabrás
  • Usar los métodos de reproducción y saber qué hace cada uno con la dirección.
  • Distinguir tiempo de tiempo total y progreso de progreso total.
  • Aplicar los patrones de interacción reversible con la timeline como estado.
  • Animar el propio timeScale para acelerar y frenar con suavidad.

Reproducción y dirección

Cinco métodos, y la diferencia entre ellos está en qué hacen con la dirección:

tl.play();      // hacia delante desde donde este
tl.pause();     // se para, conserva la direccion
tl.resume();    // reanuda SIN cambiar la direccion
tl.reverse();   // hacia atras desde donde este
tl.restart();   // al principio y hacia delante

play y reverse fijan la dirección; resume la conserva. Esa es toda la diferencia entre play y resume, y es relevante cuando la timeline estaba invertida: resume sigue yendo hacia atrás y play la da la vuelta.

Los cuatro primeros aceptan un argumento de posición —un número o una etiqueta— que hace que salten ahí antes de actuar. restart(incluirDelay) acepta un booleano para respetar o no el retardo inicial.

La dirección se consulta y se fija también con reversed():

tl.reversed();        // true si va hacia atras
tl.reversed(true);    // ponerla en reversa sin reproducir
tl.reversed(!tl.reversed());   // invertir la direccion actual

Ese último es el corazón del botón que abre y cierra: una sola timeline y un conmutador de dirección.

const menu = gsap.timeline({ paused: true });
menu.to('.panel', { xPercent: 0, duration: 0.5, ease: 'power3.out' })
    .from('.panel li', { x: 20, autoAlpha: 0, duration: 0.3, stagger: 0.05 }, '-=0.25');

boton.addEventListener('click', () => {
  menu.reversed() ? menu.play() : menu.reverse();
});

Fíjate en lo que no hay: no hay una animación de apertura y otra de cierre, no hay una variable booleana con el estado, no hay comprobación de si ya estaba abierto. La timeline es el estado. Y si el usuario pulsa a mitad del recorrido, la animación invierte desde donde esté sin saltos, porque el cabezal ya está en su sitio.

💡
Cerrar más rápido de lo que se abre

Un detalle de oficio que se nota mucho: las salidas deben ser más rápidas que las entradas, porque cuando el usuario cierra algo ya no le interesa mirarlo. Con una sola timeline eso es una línea.

boton.addEventListener('click', () => {
  menu.reversed() ? menu.timeScale(1).play() : menu.timeScale(1.6).reverse();
});

Colocar el cabezal

Cuatro formas de leer y escribir la posición, y la diferencia entre ellas es si cuentan las repeticiones.

time() y progress() excluyen las repeticiones: son la posición dentro de una iteración. totalTime() y totalProgress() incluyen todas las repeticiones y sus retardos.

tl.time();            // segundos dentro de la iteracion actual
tl.time(1.5);         // colocar el cabezal ahi
tl.progress();        // 0 a 1 dentro de la iteracion
tl.progress(0.5);     // a la mitad de la iteracion

tl.totalTime();       // segundos desde el principio de todo, con repeticiones
tl.totalProgress(1);  // al final del todo

Con una timeline sin repeticiones los cuatro coinciden y la distinción no importa. Con repeat: 2, una timeline de un segundo tiene duration() de 1 y totalDuration() de 3, y progress(0.5) te lleva a la mitad de la iteración actual, no a la mitad del conjunto. Es la causa habitual de que una barra de progreso ligada a progress() se reinicie tres veces.

seek(posicion) salta a un instante o a una etiqueta sin cambiar el estado de pausa ni la dirección. Es la operación de colocación pura, y es la que quieres para sincronizar con algo externo:

tl.seek('cierre');
tl.seek(2.4);

Y duration() como setter hace algo que sorprende: no recorta los hijos, sino que ajusta el timeScale de la timeline para que el conjunto quepa en ese tiempo manteniendo las proporciones. Es un atajo excelente cuando el diseño dice “toda la intro tiene que durar 2,5 segundos” y no quieres recalcular veinte duraciones.

maestra.duration(2.5);   // reescala el conjunto, no recorta nada

timeScale y animarlo

timeScale(valor) multiplica la velocidad: 1 es normal, 0.5 la mitad, 2 el doble. Se propaga a los descendientes multiplicándose con los suyos.

Como es un getter y setter numérico de un objeto, se puede animar:

// Frenar suavemente hasta la mitad de velocidad.
gsap.to(tl, { timeScale: 0.5, duration: 0.6, ease: 'power2.out' });

// Y acelerar de vuelta.
gsap.to(tl, { timeScale: 1, duration: 0.4 });

Ese gsap.to(tl, ...) es la demostración perfecta de que para GSAP una timeline es un objeto más: se anima una propiedad suya con un tween normal. El efecto de cámara lenta que entra y sale con suavidad, que en otras librerías es una funcionalidad, aquí es una consecuencia del modelo.

La aplicación práctica más útil es la depuración: tl.timeScale(0.1) y ves a cámara lenta lo que a velocidad normal es un borrón. La segunda más útil es responder a la interacción: ralentizar una animación de fondo mientras el usuario lee, acelerarla cuando se va.

Los patrones que este control habilita

Timeline como estado de un componente. Ya lo hemos visto con el menú: la dirección de la timeline sustituye a la variable booleana, y como consecuencia desaparece toda la clase de bugs en los que el estado y la animación se desincronizan.

Rebobinado en interacción rápida. El caso del mouseenter y mouseleave seguidos. Con una timeline por elemento guardada en el propio elemento:

document.querySelectorAll('.tarjeta').forEach((tarjeta) => {
  const tl = gsap.timeline({ paused: true });
  tl.to(tarjeta.querySelector('.imagen'), { scale: 1.08, duration: 0.4, ease: 'power2.out' })
    .to(tarjeta.querySelector('.pie'), { y: 0, autoAlpha: 1, duration: 0.3 }, '<0.05');

  tarjeta.addEventListener('mouseenter', () => tl.timeScale(1).play());
  tarjeta.addEventListener('mouseleave', () => tl.timeScale(1.8).reverse());
});

Una timeline por tarjeta, creada una vez, pausada. Pasar el ratón rápidamente por diez tarjetas no crea ni un solo tween nuevo.

Progreso ligado a un control externo. Como progress es un setter, cualquier valor entre cero y uno se puede enchufar directamente:

deslizador.addEventListener('input', (e) => {
  tl.progress(Number(e.target.value));
});

Con tres líneas tienes un depurador de coreografías mejor que la mayoría de las herramientas dedicadas.

Coordinación con lógica asíncrona. then está implementado, así que una timeline se puede esperar. Y eventCallback permite añadir o cambiar callbacks después de crearla:

tl.eventCallback('onComplete', () => cargarSiguiente());
tl.eventCallback('onComplete', null);   // quitarlo

Matar y revertir

kill() detiene la animación, la saca de su padre y la deja lista para ser recolectada. Los valores quedan donde estuvieran.

revert() hace lo mismo y además devuelve los objetivos a su estado previo, incluida la eliminación de los estilos en línea que GSAP escribió. Es lo que quieres al desmontar un componente.

// Al desmontar: no basta con kill.
tl.revert();

La diferencia importa más de lo que parece. Un kill() sobre una animación de entrada a medio camino deja los elementos a media opacidad y con transformaciones en línea que ganan a cualquier regla de CSS. El siguiente montaje del componente parte de ese estado sucio. revert() limpia.

Una timeline pausada no es gratis, y una que nunca se mata es una fuga

Dos costes que nadie contabiliza. El primero: una timeline creada con paused: true ya ha resuelto sus objetivos y, si contiene from o fromTo, ya ha escrito sus valores iniciales. Crear cien timelines pausadas al arrancar no es diferido: es cien resoluciones de selector y cien escrituras de estilo antes del primer pintado. En listas largas, eso es tiempo de arranque perdido en animaciones que quizá nunca se disparen. El patrón correcto es crear la timeline la primera vez que se necesita y guardarla en el elemento a partir de entonces.

El segundo, más grave: una timeline retiene referencias a sus objetivos. Mientras esté viva, los elementos del DOM que anima no se pueden recolectar aunque los hayas quitado del documento. Una aplicación de una sola página que crea timelines al montar y no las revierte al desmontar acumula elementos muertos indefinidamente, y el perfil de memoria sube en escalones perfectos cada vez que el usuario navega. El diagnóstico es inconfundible: nodos separados del documento que siguen contados en el volcado de memoria. La regla es tan simple como impopular: por cada timeline que creas al montar, un revert() al desmontar, sin excepciones.

⚔️ Reto práctico

Monta el depurador de coreografías: un deslizador ligado a progress(), un botón de reproducir y pausar, otro de invertir, y un campo numérico ligado a timeScale(). Añade una lista que muestre currentLabel() en vivo. Son treinta líneas y lo vas a reutilizar en todos los proyectos.