Motion en scroll sin romper el SSR ni la accesibilidad
Cómo montar animaciones con scrub y pin usando GSAP y ScrollTrigger en Next.js App Router, dejando el contenido legible sin JavaScript y con prefers-reduced-motion.
- #gsap
- #scrolltrigger
- #next.js
- #accesibilidad
- #performance
- #motion
El motion ligado al scroll es lo que separa una web que parece una plantilla de una que parece una página de producto. Pero es también la forma más rápida de romper una web: contenido que no aparece si falla el JavaScript, animaciones que marean a quien ha pedido explícitamente que no las haya, y triggers descolocados en cuanto carga una tipografía. Estas son las tres reglas con las que trabajo, y cómo las implemento con GSAP y ScrollTrigger en el App Router.
Regla uno: el contenido nace visible
El error clásico es escribir el estado inicial de la animación en el CSS: una clase con opacity: 0 que el JavaScript retira al entrar en viewport. Funciona perfecto hasta que el bundle falla, tarda, o alguien navega con JavaScript desactivado. Entonces la página se queda literalmente en blanco, con todo el contenido presente en el DOM y ninguna forma de leerlo.
La regla es simple: el HTML que sale del servidor tiene que ser el estado final, el legible. El estado inicial de la animación lo aplica GSAP desde JavaScript, nunca el CSS. Por eso todo se monta con gsap.fromTo() y no con clases: si el script no llega a ejecutarse, el navegador ya está mostrando la versión correcta.
``typescript
gsap.fromTo(
element,
{ opacity: 0, y: 40 }, // estado inicial, solo existe con JS
{
opacity: 1, y: 0, // estado final, el que renderiza el servidor
ease: 'none',
scrollTrigger: { trigger: element, start: 'top 94%', end: 'top 64%', scrub: 0.5 },
},
);
``
El detalle que evita el parpadeo es ejecutar esto en useLayoutEffect, que corre antes del pintado. Con useEffect el navegador llega a pintar un fotograma del estado final antes de que GSAP aplique el inicial, y se ve un salto.
Regla dos: prefers-reduced-motion no es un matiz
Reducir la duración de las transiciones a 0.01ms desde CSS sirve para las animaciones CSS, pero no toca nada de lo que hace GSAP. Un ScrollTrigger con scrub seguirá moviendo elementos aunque el sistema operativo tenga activada la reducción de movimiento.
La forma correcta es no crear la animación en absoluto. gsap.matchMedia() permite condicionar la creación de las animaciones a una media query, y revierte automáticamente todo lo creado dentro cuando la condición deja de cumplirse:
``typescript
const mm = gsap.matchMedia();
mm.add(
{ motion: '(prefers-reduced-motion: no-preference)', desktop: '(min-width: 768px)' },
(context) => {
const { motion, desktop } = context.conditions;
if (!motion) return; // nada se crea, el DOM queda como vino
// ...animaciones, con pin solo si desktop
},
);
return () => mm.revert();
``
Lo importante del revert() es que devuelve el DOM al estado exacto en el que estaba antes: los estilos inline que GSAP había inyectado desaparecen. Combinado con la regla uno, eso significa que quien pide menos movimiento ve exactamente la misma página que sale del servidor.
Regla tres: el pin es una decisión de escritorio
Pinear una sección con pin: true funciona muy bien en pantallas grandes y bastante mal en móviles, donde compite con el scroll del navegador, con las barras que se ocultan al desplazar y con viewports que cambian de altura a mitad de gesto. Además, un pin en un móvil consume una parte considerable de un scroll ya escaso.
Con el objeto de condiciones de matchMedia el pin se resuelve en una línea: pin: desktop. En pantallas pequeñas la misma sección conserva la animación de scrub, pero avanza con el recorrido natural de la página en lugar de fijarse.
El detalle que rompe todo lo demás: las medidas
ScrollTrigger calcula las posiciones de start y end una sola vez, al crearse. Cualquier cosa que cambie la altura del documento después descoloca todos los triggers a la vez: una tipografía que carga y hace el texto más alto, una sección que se hidrata tarde, una imagen sin dimensiones declaradas.
La solución son dos líneas y evita el 90% de los problemas de triggers que se disparan donde no deben:
``typescript
document.fonts.ready.then(() => ScrollTrigger.refresh());
window.addEventListener('load', () => ScrollTrigger.refresh());
``
A eso añado invalidateOnRefresh: true en cada trigger, para que los valores calculados a partir del tamaño del elemento se vuelvan a medir en cada refresh en lugar de quedarse con la medida del primer render.
Cómo verifico que no he roto nada
El motion es de las pocas cosas que no se pueden validar leyendo el código. Lo compruebo con un navegador real y cuatro escenarios: escritorio normal, escritorio con prefers-reduced-motion emulado, móvil, y la página entera con JavaScript desactivado. En los cuatro casos compruebo lo mismo por script: que existe un h1, que ningún bloque de texto queda con opacidad por debajo de un umbral, y que no aparece scroll horizontal.
Si con reduced motion sigue habiendo elementos a media opacidad, o si sin JavaScript falta contenido, la animación está mal montada por muy bien que se vea en el caso normal.
Conclusión
El motion bueno es el que se puede quitar entero sin que la página pierda nada esencial. Si al desactivar el JavaScript o al pedir menos movimiento la web deja de funcionar, no era una capa de presentación: era una dependencia. Y eso, en una web de negocio, es un riesgo que no compensa ningún efecto visual.
¿Construyendo algo?
Si esto te ha resonado, podemos hablar. Contactar →