wandres.dev
GSAP XII · Integración y limpieza

gsap.matchMedia: animaciones condicionales con limpieza automática

Cómo condicionar animaciones a media queries sin dejar residuos, las condiciones con nombre, la función de limpieza, y el patrón de movimiento reducido.

⏱ 19 min

Condicionar animaciones a un tamaño de pantalla parece un if con window.innerWidth y es un problema de ciclo de vida. El if se evalúa una vez, así que no reacciona al cambio; y aunque lo metas en una escucha de resize, al cruzar el umbral hacia abajo tienes que deshacer todo lo que creaste arriba, incluidos los estilos en línea que las animaciones dejaron puestos, o el layout móvil hereda transformaciones del de escritorio. gsap.matchMedia() resuelve las dos mitades, y lo hace apoyándose en el mismo mecanismo de recolección del contexto.

🎯 Al terminar esta lección sabrás
  • Crear animaciones condicionadas a una media query con limpieza automática.
  • Usar condiciones con nombre y leerlas desde el objeto que recibe la función.
  • Escribir funciones de limpieza propias sin duplicar la reversión.
  • Aplicar el patrón de movimiento reducido de forma que responda a cambios en caliente.

La forma básica

gsap.matchMedia() devuelve un objeto al que se le añaden bloques con add(). Cada bloque tiene una consulta y una función; la función se ejecuta cuando la consulta se cumple, y todo lo que cree se revierte automáticamente cuando deja de cumplirse.

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

gsap.registerPlugin(ScrollTrigger);

const mm = gsap.matchMedia();

mm.add("(min-width: 900px)", () => {
  // Solo existe en escritorio
  ScrollTrigger.create({
    trigger: ".escena",
    start: "top top",
    end: "+=2000",
    pin: true,
    scrub: 1,
  });

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

Al estrechar la ventana por debajo de 900 píxeles, la fijación se deshace, el espaciador desaparece del DOM, el ScrollTrigger se destruye y los estilos en línea que la animación había aplicado se retiran. Nada de eso hay que escribirlo.

La razón de que funcione así es que internamente crea un gsap.context() por cada bloque. La documentación lo dice explícitamente: llamar a revert() sobre el objeto de matchMedia es lo mismo que llamarlo sobre el contexto. Por eso sería redundante envolver un matchMedia dentro de un contexto.

add() devuelve el propio objeto, así que se puede encadenar. Y acepta un tercer parámetro con el ámbito de selectores, igual que el contexto; también se puede pasar un ámbito por defecto al crear el matchMedia.

const mm = gsap.matchMedia(contenedor);   // ambito por defecto para todos los bloques

mm.add("(min-width: 900px)", () => {
  gsap.from(".titulo", { autoAlpha: 0 });  // busca dentro de "contenedor"
});

Condiciones con nombre

La forma más útil no es una consulta suelta sino un objeto con varias consultas nombradas. La función se ejecuta cuando alguna de ellas se cumple, y recibe un objeto desde el que puedes consultar cuáles.

const mm = gsap.matchMedia();
const corte = 900;

mm.add(
  {
    esEscritorio: `(min-width: ${corte}px)`,
    esMovil: `(max-width: ${corte - 1}px)`,
    reduceMovimiento: "(prefers-reduced-motion: reduce)",
  },
  (contexto) => {
    const { esEscritorio, esMovil, reduceMovimiento } = contexto.conditions;

    gsap.to(".tarjeta", {
      rotation: esEscritorio ? 360 : 180,
      duration: reduceMovimiento ? 0 : 2,
    });

    if (esEscritorio) {
      ScrollTrigger.create({ trigger: ".escena", pin: true, end: "+=2000", scrub: 1 });
    }

    if (esMovil) {
      gsap.from(".tarjeta", { autoAlpha: 0, stagger: 0.06 });
    }
  }
);

La propiedad se llama exactamente conditions y es un objeto con un booleano por cada nombre que hayas definido. Los nombres los eliges tú, no hay ninguno reservado.

La ventaja frente a tres bloques separados es que el código compartido se escribe una vez y las diferencias quedan expresadas como valores en lugar de como duplicación. Y como la función se vuelve a ejecutar cada vez que cualquiera de las condiciones cambia, con la reversión previa incluida, no hay estado que arrastre.

💡
Una consulta puede ser cualquier media query

No estás limitado al ancho. (prefers-reduced-motion: reduce), (prefers-color-scheme: dark), (orientation: landscape), (hover: none), (pointer: coarse) y (update: slow) son todas válidas y todas tienen sentido para condicionar animaciones. (pointer: coarse) es especialmente útil: distingue entrada táctil de precisa mejor que cualquier umbral de ancho.

La función de limpieza

Cada bloque puede devolver una función que se ejecuta cuando la condición deja de cumplirse. Aquí hay un detalle que la documentación subraya y que conviene no equivocar: esa función se llama además de la reversión automática, no en su lugar. No debes llamar a revert() desde ella.

mm.add("(min-width: 900px)", (contexto) => {
  const alPulsar = () => gsap.to(".panel", { x: 0, duration: 0.4 });

  contexto.add("abrir", () => gsap.to(".panel", { x: 320, duration: 0.4 }));
  document.querySelector("#cerrar").addEventListener("click", alPulsar);

  return () => {
    // Solo limpieza propia. La reversion de GSAP ya ocurre sola
    document.querySelector("#cerrar").removeEventListener("click", alPulsar);
  };
});

Las escuchas del DOM son el caso número uno para esta función. Si no las retiras, al cruzar el umbral de ancho varias veces acumulas una escucha por cruce, y cada una dispara su animación: el panel se abre con una velocidad que se multiplica sin explicación aparente.

Fíjate también en contexto.add() con nombre, exactamente igual que en un contexto normal: sirve para que las animaciones creadas más tarde, desde manejadores de eventos, queden recogidas.

Movimiento reducido en serio

Este es el uso que más justifica el mecanismo, porque el planteamiento habitual tiene un fallo silencioso.

// FRAGIL: se evalua una vez al cargar y no reacciona al cambio
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) crearAnimaciones();

Ese código funciona para quien tenía la preferencia activada antes de abrir la página, y no funciona para quien la activa mientras la usa, que es exactamente lo que hace alguien a quien tu animación le está sentando mal. Con matchMedia de GSAP, el cambio se atiende en caliente y las animaciones existentes se revierten.

Y la implementación correcta no es “sin animación”, sino “con otra animación”. Reducir el movimiento significa eliminar el desplazamiento grande, el paralaje y el zoom, no dejar la interfaz muda: un desvanecido corto sigue comunicando el cambio de estado sin provocar malestar.

const mm = gsap.matchMedia();

mm.add(
  {
    normal: "(prefers-reduced-motion: no-preference)",
    reducido: "(prefers-reduced-motion: reduce)",
  },
  (contexto) => {
    const { normal } = contexto.conditions;

    gsap.from(".tarjeta", {
      autoAlpha: 0,
      y: normal ? 48 : 0,
      scale: normal ? 0.94 : 1,
      duration: normal ? 0.7 : 0.25,
      stagger: normal ? 0.08 : 0.03,
      ease: "power2.out",
      scrollTrigger: { trigger: ".rejilla", start: "top 80%", once: true },
    });

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

Con la preferencia activa, las tarjetas siguen apareciendo con un desvanecido rápido y el paralaje no se crea en absoluto. Con ella desactivada, la versión completa. Y si el usuario cambia la preferencia con la página abierta, se cambia de versión sin recargar.

Métodos del objeto

mm.revert() revierte todos los bloques activos. mm.kill() los mata, con un booleano opcional para revertir también. mm.contexts es el array de contextos, uno por bloque.

Y existe gsap.matchMediaRefresh(), que revierte todos los objetos de matchMedia activos y vuelve a ejecutar los que sigan cumpliendo condición. Es lo que necesitas cuando algo externo ha cambiado el layout de forma que las medidas ya no valen.

// Al desmontar un componente que creo su propio matchMedia
mm.revert();
matchMedia convierte las media queries de condición de estilo en condición de existencia, y esa promoción es más profunda de lo que parece

En CSS, una media query condiciona valores: dentro del bloque, ciertas propiedades toman otro valor, y fuera vuelven al anterior. El elemento existe siempre; lo que cambia es su aspecto. La cascada garantiza que al dejar de cumplirse la condición los valores se retiran limpiamente, sin residuos, porque el navegador nunca llegó a escribir nada permanente. Cuando trasladas ese modelo a JavaScript, la garantía desaparece: un tween no condiciona un valor, escribe un estilo en línea, y ese estilo permanece cuando la condición deja de cumplirse. De ahí sale la clase entera de bugs que todo el mundo ha sufrido al menos una vez: el layout móvil que aparece con un transform heredado del de escritorio, el elemento fijado cuyo espaciador de 800 píxeles sigue empujando el contenido en una pantalla donde ya no hay fijación, la opacidad a 0,3 de una animación que nunca llegó a completarse antes del cambio de umbral. gsap.matchMedia() restaura la garantía de la cascada en un terreno donde no existía, y lo hace con la única técnica que puede funcionar: registrar todo lo que se escribe para poder desescribirlo. Pero hay una consecuencia conceptual que va más allá del bug concreto. Al condicionar la existencia de las animaciones en lugar de sus valores, cambias qué significa una media query en tu proyecto: deja de ser “en móvil esto se ve distinto” y pasa a ser “en móvil esto no existe”. Y eso es lo correcto, porque una escena fijada de 2000 píxeles de scroll en un móvil no es la misma escena más pequeña; es una decisión de producto distinta que probablemente no deberías tomar. La herramienta te empuja a formular la pregunta en los términos adecuados, y esa es la marca de una buena abstracción: no solo evita errores, sino que hace más difícil plantearse mal el problema.

⚔️ Condicionar de verdad
  1. Crea una escena fijada solo por encima de 900 píxeles y estrecha la ventana. Comprueba en el inspector que el espaciador desaparece.
  2. Monta lo mismo con un if sobre window.innerWidth y compara el residuo que queda al estrechar.
  3. Usa condiciones con nombre para tener tres variantes de la misma animación sin duplicar código.
  4. Añade una escucha de clic dentro de un bloque sin retirarla en la limpieza, cruza el umbral cinco veces y observa cómo se multiplica el efecto.
  5. Cambia la preferencia de movimiento reducido de tu sistema con la página abierta y verifica que la animación cambia sin recargar.