wandres.dev
GSAP XII · Integración y limpieza

gsap.context: por qué existe y qué recoge

El problema de recopilar todo lo que crea un bloque de código, la diferencia entre revert y kill, el ámbito de los selectores, y cómo añadir código después.

⏱ 19 min

Antes de que existiera gsap.context(), limpiar las animaciones de un componente consistía en guardar cada tween, cada timeline y cada instancia de ScrollTrigger en una variable, mantener esa lista actualizada a mano, y llamar a kill() sobre cada una en el desmontaje. Era código aburrido, era fácil olvidarse de una pieza, y el olvido no producía ningún error visible sino una degradación lenta. El contexto elimina esa contabilidad: recoge automáticamente todo lo que se cree mientras se ejecuta una función y te da un único botón para deshacerlo todo.

🎯 Al terminar esta lección sabrás
  • Explicar qué recopila un contexto y en qué momento lo hace.
  • Distinguir revert() de kill() y saber cuál corresponde a cada situación.
  • Usar el ámbito de selectores para no depender de identificadores globales.
  • Añadir código al contexto después de su creación con add() y excluirlo con ignore().

Qué recoge y cuándo

gsap.context(funcion, ambito) ejecuta la función de inmediato y, mientras dura esa ejecución, apunta todo lo que GSAP crea: tweens, timelines, instancias de ScrollTrigger, Draggables, SplitText, Observers. Después devuelve un objeto con el que puedes deshacerlo todo de una vez.

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

const ctx = gsap.context(() => {
  gsap.from(".titulo", { autoAlpha: 0, y: 40, duration: 0.8 });

  gsap.to(".fondo", {
    yPercent: -25,
    ease: "none",
    scrollTrigger: { trigger: ".seccion", scrub: 1, start: "top bottom", end: "bottom top" },
  });

  ScrollTrigger.create({
    trigger: ".seccion",
    start: "top 50%",
    onToggle: (self) => document.body.classList.toggle("oscuro", self.isActive),
  });
});

// Una sola llamada deshace las tres cosas
ctx.revert();

La palabra clave es mientras. El contexto no vigila permanentemente; solo recoge lo que ocurra durante la ejecución síncrona de la función. Un tween creado dentro de un setTimeout, dentro de un manejador de clic o dentro de una promesa que se resuelve después no queda recogido, porque para entonces la función ya terminó. Ese matiz es la causa del noventa por ciento de las fugas que sobreviven a la adopción del contexto, y tiene solución, que es lo último de esta lección.

revert frente a kill

Ambos métodos existen y no hacen lo mismo.

revert() deshace: mata las animaciones recogidas y además restaura los elementos a su estado anterior, quitando los estilos en línea que GSAP hubiera aplicado. Después de un revert(), el DOM está como si nada se hubiera animado nunca.

kill() mata las animaciones; acepta un booleano opcional que indica si además debe revertir.

La distinción importa en un caso concreto. Si vas a desmontar el componente y sus elementos van a desaparecer del DOM, da igual cuál uses. Si el componente permanece y solo quieres detener las animaciones —por ejemplo al cambiar de condición de media query—, revert() es lo que quieres, porque deja los elementos limpios para que la siguiente configuración parta de cero en lugar de heredar estilos en línea de la anterior.

La documentación es explícita sobre un punto: revert() es permanente para lo que había dentro. Las animaciones se revierten y se matan, el contexto se vacía y todo queda disponible para el recolector de basura. Pero el contexto sigue existiendo: puedes añadirle cosas nuevas después y volver a revertirlo.

Y hay una vía adicional para código de limpieza propio: si la función del contexto devuelve otra función, esa función se llama al revertir.

const ctx = gsap.context(() => {
  const observador = new ResizeObserver(() => ScrollTrigger.refresh());
  observador.observe(document.querySelector(".panel"));

  gsap.from(".tarjeta", { autoAlpha: 0, stagger: 0.1 });

  // Limpieza propia: lo que GSAP no puede saber
  return () => observador.disconnect();
});

Ese patrón es el que convierte al contexto en el punto único de limpieza del componente, y no solo de sus animaciones.

El ámbito de selectores

El segundo parámetro convierte todo el texto de selector de dentro en relativo a un elemento. Admite un elemento, texto de selector, o una referencia de React o de Angular.

const contenedor = document.querySelector("#tarjeta-7");

const ctx = gsap.context(() => {
  // ".titulo" busca SOLO dentro de #tarjeta-7
  gsap.from(".titulo", { autoAlpha: 0, y: 20 });
  gsap.from(".cuerpo p", { autoAlpha: 0, stagger: 0.05 });
}, contenedor);

Eso resuelve un problema real y molesto: en una página con veinte instancias del mismo componente, cada una necesita animar sus elementos, y sin ámbito habría que generar identificadores únicos o pasar referencias a todas partes. Con ámbito, el mismo código funciona para las veinte y cada una toca lo suyo.

El objeto de contexto expone además un selector que es una función, para cuando necesitas consultar elementos con el mismo ámbito fuera de las llamadas de GSAP.

ℹ️
No es un mecanismo de control

La documentación lo dice sin rodeos: un contexto no está pensado para controlar animaciones. No tiene play, ni pause, ni progress. Para controlar, existen las timelines. El contexto es un mecanismo de recolección y limpieza, y confundir ambas cosas lleva a arquitecturas raras donde se crea un contexto por animación.

add e ignore: lo que ocurre después

Aquí está la solución al problema que abríamos al principio. Cualquier animación que se cree después de que la función del contexto termine queda fuera de su radar. add() es la forma de meterla dentro.

Tiene dos formas. Con una función suelta como primer argumento, la ejecuta ahora mismo y recoge lo que cree. Con un nombre y una función, registra un método en el contexto que, cada vez que se llame, recogerá lo que cree.

const ctx = gsap.context((self) => {
  gsap.from(".panel", { autoAlpha: 0, duration: 0.6 });

  // Registrar un metodo que se podra llamar despues
  self.add("resaltar", (elemento) => {
    gsap.to(elemento, { scale: 1.06, duration: 0.25, yoyo: true, repeat: 1 });
  });
}, contenedor);

// Cualquier animacion creada aqui SI queda recogida por el contexto
document.querySelectorAll(".fila").forEach((fila) => {
  fila.addEventListener("mouseenter", () => ctx.resaltar(fila));
});

Fíjate en que el manejador de eventos no queda recogido: el contexto no sabe nada de escuchas del DOM. Si el componente puede desmontarse, hay que retirarlas en la función de limpieza que devuelves.

ignore() hace lo contrario: lo que se cree dentro de la función que le pases no entra en el contexto y sobrevive al revert().

gsap.context((self) => {
  gsap.from(".contenido", { autoAlpha: 0 });   // se recoge

  self.ignore(() => {
    // Una animacion global que no debe morir con este componente
    gsap.to(document.body, { backgroundColor: "#181825", duration: 0.4 });
  });
}, contenedor);

Es una válvula de escape que conviene usar poco: si algo no debe morir con el componente, normalmente es señal de que no debería crearse dentro del componente.

Lo que el contexto no sabe

Merece la pena tener la lista clara, porque marca la frontera entre lo que se limpia solo y lo que sigue siendo responsabilidad tuya:

Escuchas de eventos del DOM que hayas añadido con addEventListener. Observadores de tipo ResizeObserver, IntersectionObserver o MutationObserver. Temporizadores de setTimeout y setInterval —aunque gsap.delayedCall() sí se recoge, por ser de GSAP—. Peticiones de red pendientes. Y elementos que hayas insertado en el DOM fuera del contenedor.

Todo eso va en la función de limpieza que devuelves. El contexto es el sitio donde ponerla, no el que la escribe por ti.

const ctx = gsap.context(() => {
  const alRedimensionar = () => ScrollTrigger.refresh();
  const temporizador = setInterval(actualizarReloj, 1000);

  window.addEventListener("resize", alRedimensionar);
  gsap.from(".reloj", { autoAlpha: 0 });

  return () => {
    window.removeEventListener("resize", alRedimensionar);
    clearInterval(temporizador);
  };
}, contenedor);
El contexto no es azúcar sintáctico: convierte un problema de contabilidad manual en uno de ámbito léxico, y eso lo hace inolvidable

La razón de que este mecanismo funcione tan bien no está en lo que hace sino en dónde te obliga a escribirlo. Antes del contexto, la limpieza vivía lejos de la creación: los tweens se creaban en un sitio y se mataban en otro, normalmente en una función de desmontaje al final del archivo, y mantener sincronizadas ambas listas era trabajo manual sujeto a olvidos. Ese patrón tiene un nombre en la literatura y es el mismo que produce las fugas de memoria en los lenguajes con gestión manual: la adquisición y la liberación de un recurso ocurren en puntos distintos del programa, y nada obliga a que la segunda exista. La solución que la industria encontró hace décadas, con nombres como el patrón de adquisición ligada al ámbito o el bloque with de otros lenguajes, siempre es la misma: hacer que la liberación esté ligada léxicamente a la adquisición, de modo que la estructura del código garantice el emparejamiento. Eso es exactamente lo que hace gsap.context(). Todo lo que se crea dentro de esas llaves pertenece al contexto por construcción, no por disciplina, y ya no hay una lista que mantener porque la lista es la propia función. Fíjate en que la función de limpieza que devuelves está escrita junto a las cosas que limpia, a veinte líneas del addEventListener correspondiente y no a doscientas. Ese es el cambio de verdad, y es la razón de que quien adopta el contexto deje de tener fugas casi de golpe. Y también explica la única frontera que sigue costando: lo que se crea de forma diferida, en un manejador de clic o en un temporizador, escapa al ámbito léxico y por eso necesita un mecanismo explícito para volver a entrar. Cada vez que veas add() o contextSafe, lee “estoy reincorporando al ámbito algo que se salió de él”, y sabrás exactamente por qué esa API existe.

⚔️ Recoger y soltar
  1. Crea un contexto con tres animaciones y una instancia de ScrollTrigger, y comprueba con ScrollTrigger.getAll().length que revert() las elimina todas.
  2. Crea un tween dentro de un setTimeout en la función del contexto y verifica que sobrevive al revert().
  3. Arregla ese caso con ctx.add() con nombre y comprueba que ahora sí se recoge.
  4. Monta el mismo componente tres veces en la página con ámbitos distintos y comprueba que cada uno anima solo lo suyo.
  5. Añade un ResizeObserver y una escucha de ventana, y escribe la función de limpieza que los retire.