shipping/JOG — 03:41 GMT+7/48.3 °C CPU/v 07.0 · build 2026.05.12
lat −7.7956 · lon 110.3695/ntwk · online/open for projects
back to notesnotes2026gsap-scrollsmoother-horizontal
[ essay no. 04 ]craftmay 13, 20263 min626 wordsrevision 1live

Horizontal Scroll

GSAP's scroll toolkit driving a pinned horizontal section. Full scroll-speed control, momentum, and zero Lenis conflicts — because ScrollSmoother IS the scroller for that section.

d
devrangga hazza mahiswaracreative engineer · jogja, id
share on 𝕏
01setup.
02smooth.
03pin.
04scrub.
05ship.
drag · or use arrows
01

ScrollSmoother vs Lenis

If you're already using Lenis for smooth scroll, you have two options for a horizontal section:

  1. Keep Lenis, use GSAP ScrollTrigger — pause Lenis on the horizontal section and let ScrollTrigger scrub the x-position directly. Simpler, but you lose momentum inside the section.
  2. Replace Lenis with ScrollSmoother — GSAP's own smooth scroller; handles both vertical smoothing and horizontal sections natively. More setup, but no conflicts.

This guide uses option 1 — the pragmatic choice for adding a horizontal section to an existing site without ripping out the scroll infrastructure.

02

The pinning concept

A horizontal scroll section is really just a pinned element with a ScrollTrigger scrubbing x.

┌──────────────────────────────────┐
│  scroll container (tall, ~500vh) │
│  ┌────────────────────────────┐  │
│  │  sticky viewport (100vh)   │  │  ← pinned here
│  │  ┌──────────────────────┐  │  │
│  │  │  track (panels × N)  │  │  │  ← x moves as you scroll
│  │  └──────────────────────┘  │  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

The container is tall enough to scroll through all panels. The sticky viewport stays fixed. The track moves left as scroll progress increases.

03

Build the HTML structure

"use client";
 
import { useRef, useEffect } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
 
gsap.registerPlugin(ScrollTrigger);
 
const PANELS = ["craft", "motion", "code", "design", "ship"];
 
export function HorizontalSection() {
  const containerRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
 
  return (
    <div ref={containerRef} style={{ height: `${PANELS.length * 100}vh` }}>
      <div className="sticky top-0 h-screen overflow-hidden">
        <div ref={trackRef} className="flex h-full will-change-transform">
          {PANELS.map((label) => (
            <div
              key={label}
              className="shrink-0 w-screen h-full flex items-center justify-center"
            >
              <h2 className="font-display text-[120px] text-white">{label}.</h2>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Each panel is w-screen wide so the total track width is 100vw × N.

04

Wire up ScrollTrigger

useEffect(() => {
  const container = containerRef.current;
  const track = trackRef.current;
  if (!container || !track) return;
 
  const totalWidth = track.scrollWidth - window.innerWidth;
 
  const tween = gsap.to(track, {
    x: -totalWidth,
    ease: "none",
    scrollTrigger: {
      trigger: container,
      start: "top top",
      end: "bottom bottom",
      scrub: 1,
      invalidateOnRefresh: true,
    },
  });
 
  return () => {
    tween.kill();
    ScrollTrigger.getAll().forEach((t) => t.kill());
  };
}, []);

scrub: 1 adds a 1-second smoothing lag — the track "catches up" to scroll position. Increase for more smoothing, set to true for instant follow.

05

Animate content within panels

Each panel can have its own ScrollTrigger child animation. The critical prop is containerAnimation — it tells ScrollTrigger this element is inside a horizontal scroll, so use x-progress to determine visibility.

panels.forEach((panel) => {
  const heading = panel.querySelector("h2");
  if (!heading) return;
 
  gsap.fromTo(
    heading,
    { opacity: 0, y: 40 },
    {
      opacity: 1,
      y: 0,
      duration: 0.6,
      ease: "power3.out",
      scrollTrigger: {
        trigger: panel,
        containerAnimation: tween,  // key: use x-progress, not y-progress
        start: "left 80%",
        toggleActions: "play none none reverse",
      },
    },
  );
});
06

Pause Lenis inside the section

If you're using Lenis for global smooth scroll, pause it while the user is inside the horizontal container to prevent conflicts.

useEffect(() => {
  const lenis = (window as Window & { __lenis?: { stop: () => void; start: () => void } }).__lenis;
 
  ScrollTrigger.create({
    trigger: containerRef.current,
    start: "top top",
    end: "bottom bottom",
    onEnter: () => lenis?.stop(),
    onLeave: () => lenis?.start(),
    onEnterBack: () => lenis?.stop(),
    onLeaveBack: () => lenis?.start(),
  });
}, []);
07

Handle resize

Horizontal scroll widths are pixel values calculated at mount time. On resize they're stale. invalidateOnRefresh: true on the ScrollTrigger recalculates automatically, but call ScrollTrigger.refresh() whenever other layout changes happen too (font loaded, images loaded, accordion expanded).

useEffect(() => {
  window.addEventListener("resize", ScrollTrigger.refresh);
  return () => window.removeEventListener("resize", ScrollTrigger.refresh);
}, []);
— end of essay · published may 13, 2026 · 626 words · 3 min
[ if this moved you ]

keep reading.

three essays in the same key · pick one