Intro
Pin the section, move the track with GSAP, and let the scroll distance drive the whole sequence.
This is the pattern I use for cinematic horizontal sections: a scrollable container, a track that moves sideways with GSAP, and a small wheel handler that turns vertical input into horizontal movement.
Hover the block and scroll. The section converts your wheel movement into a horizontal track animation.
Pin the section, move the track with GSAP, and let the scroll distance drive the whole sequence.
Pin the section, move the track with GSAP, and let the scroll distance drive the whole sequence.
Pin the section, move the track with GSAP, and let the scroll distance drive the whole sequence.
Pin the section, move the track with GSAP, and let the scroll distance drive the whole sequence.
Pin the section, move the track with GSAP, and let the scroll distance drive the whole sequence.
Pin the section, move the track with GSAP, and let the scroll distance drive the whole sequence.
Start by bringing in GSAP, the React hook, and ScrollTrigger so the animation can stay in sync with scroll position.
1import gsap from "gsap"
2import { ScrollTrigger } from "gsap/ScrollTrigger"
3import { useGSAP } from "@gsap/react"
4
5gsap.registerPlugin(ScrollTrigger)The core structure is just an overflow container plus a flex track. Each panel gets a fixed minimum width so GSAP has real distance to travel.
1<div ref={containerRef} className="overflow-x-auto overflow-y-hidden">
2 <div ref={trackRef} className="flex h-full items-center gap-4 px-6">
3 {panels.map((panel) => (
4 <article className="tutorial-horizontal-scroll-section min-w-[22rem]">
5 {panel.title}
6 </article>
7 ))}
8 </div>
9</div>The tween translates the whole track by the difference between its full width and the visible container width. ScrollTrigger handles the scrubbing for you.
1useGSAP(() => {
2 const container = containerRef.current
3 const track = trackRef.current
4 if (!container || !track) return
5
6 const tween = gsap.to(track, {
7 x: () => -(track.scrollWidth - container.clientWidth),
8 ease: "none",
9 scrollTrigger: {
10 trigger: track,
11 scroller: container,
12 horizontal: true,
13 start: "left left",
14 end: () => track.scrollWidth - container.clientWidth,
15 scrub: 0.6,
16 },
17 })
18
19 return () => {
20 tween.scrollTrigger?.kill()
21 tween.kill()
22 }
23})Without this part, trackpads feel good but normal mouse wheels feel awkward. Intercepting `deltaY` and mapping it to `scrollLeft` makes the interaction feel much more natural.
1const handleWheel = (event: WheelEvent) => {
2 if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
3 event.preventDefault()
4 container.scrollLeft += event.deltaY
5 }
6}
7
8container.addEventListener("wheel", handleWheel, { passive: false })