ScrollSmoother vs Lenis
If you're already using Lenis for smooth scroll, you have two options for a horizontal section:
- 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.
- 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.
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.
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.
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.
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",
},
},
);
});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(),
});
}, []);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);
}, []);