wandres.dev
GSAP VII · ScrollTrigger: pin y scrub

Secciones horizontales: el patrón de xPercent y containerAnimation

Cómo se construye un carril horizontal movido por scroll vertical, por qué xPercent y no left, y cómo disparar animaciones dentro del carril con containerAnimation.

⏱ 20 min

El carril horizontal es el efecto que más se pide y el que peor se implementa. La razón es que el planteamiento intuitivo —un contenedor con overflow-x: auto y una escucha del scroll— produce un desastre en móvil, pelea con el scroll nativo y no se puede sincronizar con nada. El patrón correcto es exactamente el contrario: no hay scroll horizontal en absoluto. Hay una sección fijada, un carril que se desplaza con transform, y una ilusión bien construida. Y hay un problema derivado que casi nadie resuelve: cómo disparar animaciones cuando un elemento entra en pantalla horizontalmente.

🎯 Al terminar esta lección sabrás
  • Construir un carril horizontal fijado con xPercent y calcular su end correctamente.
  • Explicar por qué xPercent es preferible a x, a left y al scroll nativo.
  • Usar containerAnimation para disparar animaciones dentro del carril.
  • Reconocer las restricciones de containerAnimation y por qué existen.

No hay scroll horizontal

El montaje tiene tres piezas. Una sección contenedora que se fija. Dentro, un carril con display: flex cuyo ancho total es el de todos los paneles juntos. Y una animación que desplaza el carril hacia la izquierda mientras la sección está fijada.

<section class="escena">
  <div class="carril">
    <article class="panel">Uno</article>
    <article class="panel">Dos</article>
    <article class="panel">Tres</article>
    <article class="panel">Cuatro</article>
  </div>
</section>
.escena { overflow: hidden; }
.carril { display: flex; width: max-content; }
.panel { width: 100vw; height: 100vh; flex: 0 0 100vw; }
gsap.registerPlugin(ScrollTrigger);

const paneles = gsap.utils.toArray(".panel");

const carril = gsap.to(paneles, {
  xPercent: -100 * (paneles.length - 1),
  ease: "none",
  scrollTrigger: {
    trigger: ".escena",
    pin: true,
    scrub: 1,
    end: () => "+=" + document.querySelector(".carril").offsetWidth,
  },
});

Tres detalles de ese código no son cosméticos.

xPercent y no x. xPercent: -100 significa “desplázate el cien por cien de tu propio ancho”, así que la fórmula funciona igual con paneles de 100vw que con paneles de 640px, y sobrevive a un cambio de tamaño de la ventana sin recalcular nada. Con x en píxeles tendrías que medir y volver a medir. Y con left estarías animando una propiedad que dispara layout en cada frame en lugar de una transformación que resuelve el compositor.

Se anima el array de paneles, no el carril. Podrías animar el carril entero, pero animar los paneles hace que cada uno tenga su propia transformación, y eso es lo que permite después efectos individuales por panel.

El end es una función. El ancho del carril depende del ancho de la ventana; si lo calculas en línea, se congela en el valor de la carga inicial y al girar un móvil la escena se queda corta o larga. Dentro de una función se reevalúa en cada refresco.

⚠️
width: max-content y el desbordamiento

Sin width: max-content en el carril, el contenedor flex intentará encajar los cuatro paneles en el ancho disponible y los comprimirá. Y sin overflow: hidden en la sección, el carril desbordado hará aparecer una barra de scroll horizontal en el body que arruina el efecto y estropea las medidas de ScrollTrigger. Los dos van juntos.

containerAnimation: disparar dentro del carril

Ahora el problema derivado. Dentro del panel tres hay un titular que quieres revelar cuando ese panel entre en pantalla. Un ScrollTrigger normal no puede saberlo: la posición del titular en el flujo del documento nunca cambia, porque el movimiento horizontal no es scroll, es una transformación. Para ScrollTrigger, ese titular lleva todo el rato en el mismo sitio.

containerAnimation resuelve exactamente eso. Le pasas el tween que mueve el carril y la instancia interpreta start y end respecto al avance de esa animación en lugar de respecto al scroll.

gsap.from(".panel-3 .titular", {
  autoAlpha: 0,
  x: 80,
  duration: 0.6,
  scrollTrigger: {
    trigger: ".panel-3 .titular",
    containerAnimation: carril,
    start: "left 70%",
    end: "left 30%",
    toggleActions: "play none none reverse",
  },
});

Fíjate en que las posiciones se escriben con left y right en lugar de top y bottom, porque el eje relevante es el horizontal. Y fíjate en que carril es la variable del tween anterior: por eso lo guardamos.

Las restricciones de containerAnimation son tres, y las tres tienen la misma raíz.

La animación del contenedor debe usar ease: "none". Si no es lineal, la relación entre el progress del carril y la posición real de los paneles deja de ser proporcional, y las posiciones calculadas no coinciden con nada.

No se puede fijar ni ajustar dentro de una instancia con containerAnimation. Ni pin ni snap. Ya estás dentro de una escena fijada; fijar dentro de una fijación no está soportado.

No animes horizontalmente el elemento disparador. Si lo haces, sus posiciones dejan de corresponder con el movimiento del carril. Si es inevitable, compensa desplazando los valores de start y end en la misma cantidad.

Los fallos que aparecen en producción

La escena se queda corta o larga en móvil. Casi siempre es el end calculado en línea. Ponlo en una función.

Al girar el móvil todo se descuadra. El refresh() se dispara con el resize, pero xPercent ya aplicado sobre elementos con anchos nuevos puede dejar valores obsoletos en la animación. Añade invalidateOnRefresh: true a la instancia del carril para que la animación descarte sus valores de inicio memorizados y los vuelva a leer.

El carril se mueve pero el contenido de dentro no se ve. Suele ser un overflow mal puesto o un ancestro con transform que rompe la fijación.

En iOS aparece una barra de scroll horizontal fantasma. El desbordamiento del carril se propaga. Además del overflow: hidden en la escena, añade overflow-x: clip al elemento raíz si el problema persiste.

const carril = gsap.to(paneles, {
  xPercent: -100 * (paneles.length - 1),
  ease: "none",
  scrollTrigger: {
    trigger: ".escena",
    pin: true,
    scrub: 1,
    invalidateOnRefresh: true,
    end: () => "+=" + document.querySelector(".carril").offsetWidth,
  },
});
El carril horizontal es una mentira coherente, y su calidad depende de que no se le vea ninguna costura

Lo que estás construyendo aquí no es una interfaz horizontal: es una simulación de scroll horizontal montada sobre scroll vertical, y toda la dificultad viene de que hay que mantener la coherencia de la mentira en todas las dimensiones a la vez. La barra de scroll del navegador sigue siendo vertical y sigue midiendo la altura del documento, así que el “recorrido horizontal” que percibe el usuario es en realidad una porción del recorrido vertical del documento; por eso el end se expresa como una distancia vertical calculada a partir de un ancho, que es una de esas líneas de código que solo tiene sentido si sabes exactamente lo que estás simulando. La rueda del ratón sigue produciendo eventos verticales, lo cual es en realidad una ventaja porque el usuario no tiene que aprender un gesto nuevo. Pero el teclado no colabora: la tecla de tabulación mueve el foco al siguiente elemento enfocable, y el navegador lo desplazará a la vista usando el mecanismo nativo, que aquí no mueve el carril sino que altera el scroll de forma imprevista. Un enlace en el panel cuatro es accesible por teclado y visualmente inalcanzable a la vez. Ese es el agujero real de este patrón y casi ninguna implementación lo tapa. Se tapa escuchando el evento de foco y llevando el scroll a la posición vertical que corresponde a ese panel, calculada con la misma aritmética del end. Y hay una lección más general debajo: cada vez que reemplazas un mecanismo nativo del navegador por una simulación, heredas la obligación de reimplementar todas las funciones que el mecanismo nativo traía gratis, incluidas las que nunca has usado. El scroll nativo trae navegación por teclado, búsqueda en página que desplaza hasta el resultado, restauración de posición al volver atrás y anclajes. Una simulación no trae ninguna, y cada una que te falte es un usuario que no puede usar tu página.

⚔️ Construir el carril completo
  1. Monta el carril de cuatro paneles del ejemplo y comprueba con marcadores que la escena empieza y acaba donde esperas.
  2. Cambia xPercent por x en píxeles y redimensiona la ventana. Explica exactamente qué se rompe.
  3. Añade un revelado dentro del panel tres con containerAnimation y ajusta start hasta que dispare en el momento correcto.
  4. Quita ease: "none" del carril y observa cómo se desalinean las posiciones de la instancia interior.
  5. Pon un enlace en el último panel y navega hasta él con la tecla de tabulación. Describe qué pasa y esboza cómo lo arreglarías.