How to Implement the Horizontal-Pan Pattern with GSAP ScrollTrigger in taste-skill
The taste-skill repository ships a ready-to-use HorizontalPan component that creates a smooth horizontal-pan effect while the user scrolls vertically, implemented with GSAP ScrollTrigger and wrapped in a React client component.
The horizontal-pan pattern transforms a standard vertical scroll into an immersive horizontal sliding experience. In the Leonxlnx/taste-skill project, this pattern is implemented as a reusable React component that leverages GSAP's ScrollTrigger plugin to pin the viewport and scrub through a horizontally overflowing track.
Prerequisites and Installation
Before implementing the pattern, ensure your project includes GSAP and the motion library for animation and accessibility detection.
npm install gsap motion
The HorizontalPan Component Architecture
Client-Side Registration
Create the component at src/components/HorizontalPan.tsx. The file must start with the "use client" directive since GSAP operates exclusively in the browser. Register the ScrollTrigger plugin once at the module level to avoid duplicate registration errors.
Refs and Accessibility Guards
The component uses two useRef hooks: wrap for the outer section that gets pinned, and track for the inner scrolling container. It also checks useReducedMotion() to respect user accessibility preferences, short-circuiting the animation if the user prefers reduced motion.
Dynamic Distance Calculation
The horizontal travel distance is computed dynamically as track.current.scrollWidth - window.innerWidth. This ensures the scroll length perfectly matches the amount of horizontal content overflow, regardless of viewport size.
ScrollTrigger Configuration
A single gsap.to animates the track's x property from 0 to -distance. The ScrollTrigger configuration pins the wrapper (pin: true) when it reaches the top of the viewport (start: "top top"), sets the scroll duration to match the travel distance (end: () => +=${distance}), and syncs the animation to scroll position with scrub: 1.
Complete Implementation
// src/components/HorizontalPan.tsx
"use client";
import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useReducedMotion } from "motion/react";
gsap.registerPlugin(ScrollTrigger);
export function HorizontalPan({ children }: { children: React.ReactNode }) {
const wrap = useRef<HTMLDivElement>(null); // outer section (pinned)
const track = useRef<HTMLDivElement>(null); // inner flex track
const reduce = useReducedMotion();
useEffect(() => {
// Bail out if reduced-motion is preferred or refs are missing
if (reduce || !wrap.current || !track.current) return;
const ctx = gsap.context(() => {
// Horizontal distance the track must travel
const distance = track.current!.scrollWidth - window.innerWidth;
// Animate the track → left as the user scrolls down
gsap.to(track.current, {
x: -distance,
ease: "none",
scrollTrigger: {
trigger: wrap.current,
start: "top top", // pin when wrapper hits viewport top
end: () => `+=${distance}`, // scroll length = travel distance
pin: true, // keep the wrapper fixed while scrolling
scrub: 1, // sync animation to scroll position
invalidateOnRefresh: true, // recalc on resize/orientation change
},
});
}, wrap); // limit context to wrapper element
// Clean up on unmount
return () => ctx.revert();
}, [reduce]);
return (
<section ref={wrap} className="relative overflow-hidden">
{/* The flex track that will slide horizontally */}
<div ref={track} className="flex h-[100dvh] items-center">
{children}
</div>
</section>
);
}
Usage Example
Import the component and wrap any content you want to scroll horizontally. Each child should occupy the full viewport width to create the panel-sliding effect.
import { HorizontalPan } from "@/components/HorizontalPan";
export default function Demo() {
return (
<HorizontalPan>
{/* Any number of full‑width panels – e.g., images or cards */}
<div className="w-screen flex-none bg-gray-100">Panel 1</div>
<div className="w-screen flex-none bg-gray-200">Panel 2</div>
<div className="w-screen flex-none bg-gray-300">Panel 3</div>
</HorizontalPan>
);
}
How It Works Under the Hood
According to the taste-skill source code in skills/taste-skill/SKILL.md (lines 38-70), the pattern creates a pinned container that remains fixed in the viewport while the user scrolls vertically. The horizontal distance is calculated by comparing the track's total scroll width against the viewport width.
The animation uses ease: "none" to create a direct 1:1 relationship between scroll position and horizontal translation. The scrub property set to 1 ensures the animation follows the scroll precisely without smoothing or delay, while invalidateOnRefresh: true recalculates distances when the window resizes.
Summary
- The HorizontalPan component in taste-skill converts vertical scroll into horizontal translation using GSAP ScrollTrigger.
- The implementation requires
"use client"and registers ScrollTrigger at the module level insrc/components/HorizontalPan.tsx. - Accessibility is handled via
useReducedMotion()from the motion library, which disables the animation when preferred. - Dynamic calculation of
scrollWidth - innerWidthensures the scroll distance always matches the content width. - Cleanup is managed through
gsap.context()and thectx.revert()return function, preventing memory leaks on unmount.
Frequently Asked Questions
Why does the component use "use client"?
The "use client" directive is required because GSAP and ScrollTrigger access browser-only APIs like window, document, and scrollWidth. Without this directive, Next.js would attempt to render the component on the server, causing reference errors during hydration.
How does the horizontal-pan pattern handle reduced motion preferences?
The component imports useReducedMotion from motion/react and checks the returned boolean before initializing any GSAP animations. If the user has enabled reduced motion in their system settings, the effect short-circuits and the content displays as a standard vertical layout without pinning or horizontal translation.
What happens if the content width changes after initial render?
The ScrollTrigger configuration includes invalidateOnRefresh: true, which forces GSAP to recalculate the scroll distance when the window resizes or orientation changes. For dynamic content changes after mount, you would need to manually call ScrollTrigger.refresh() or reinitialize the animation within a useEffect watching the content dependencies.
Where is the canonical reference for this implementation?
The canonical implementation is documented in skills/taste-skill/SKILL.md at lines 38-70 in the Leonxlnx/taste-skill repository. This file contains the annotated skeleton, implementation notes, and architectural conventions specific to the taste-skill codebase.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →