# How to Create Horizontal Scroll Pan Animations with GSAP ScrollTrigger in React

> Learn to build horizontal scroll pan animations in React using GSAP ScrollTrigger. Pin a wrapper and translate an inner track based on scroll distance, inspired by the taste-skill repo.

- Repository: [Leon Lin/taste-skill](https://github.com/Leonxlnx/taste-skill)
- Tags: how-to-guide
- Published: 2026-06-05

---

**You can create horizontal scroll pan animations with GSAP ScrollTrigger by pinning a wrapper element and translating an inner track horizontally based on the calculated scroll distance between the track width and the viewport width, as implemented in the Leonxlnx/taste-skill repository.**

The `taste-skill` repository provides a reusable client-side React component that unlocks smooth horizontal scroll pan animations with GSAP ScrollTrigger. This pattern keeps the wrapper pinned while the user scrolls vertically, driving the inner content sideways to create a cinematic pan effect. All implementation details are documented in the repository's [`SKILL.md`](https://github.com/Leonxlnx/taste-skill/blob/main/SKILL.md) definition file and rely strictly on GSAP and the ScrollTrigger plugin.

## How the HorizontalPan Component Works

The canonical implementation in [`skills/taste-skill/SKILL.md`](https://github.com/Leonxlnx/taste-skill/blob/main/skills/taste-skill/SKILL.md) follows a strict seven-step architecture:

- **Client-only execution** – The file begins with `"use client"` so the code runs exclusively in the browser.
- **DOM refs** – Two `useRef` hooks capture the wrapper (`wrap`) that gets pinned and the inner track (`track`) that slides horizontally.
- **Reduced-motion guard** – `useReducedMotion()` from `motion/react` disables the animation for users who prefer reduced motion.
- **GSAP Context** – `gsap.context()` scopes all animations to the wrapper element, making cleanup automatic on unmount.
- **Distance calculation** – The scroll distance equals the total width of the track minus the viewport width.
- **Scroll-linked tween** – A `gsap.to()` tween translates the track leftward while ScrollTrigger maps vertical scroll progress to horizontal movement.
- **Automatic cleanup** – The returned `ctx.revert()` call removes triggers and tweens when the component unmounts.

### Calculating the Horizontal Distance

Before creating the tween, the component computes how far the track must travel:

```ts
const distance = track.current!.scrollWidth - window.innerWidth

```

This value represents the exact number of pixels the track needs to shift left to reveal its overflow content.

### Configuring the ScrollTrigger Pin

The GSAP tween moves the track while ScrollTrigger handles the pinning and scrubbing according to the repository's canonical skeleton:

```ts
gsap.to(track.current, {
  x: -distance,
  ease: "none",
  scrollTrigger: {
    trigger: wrap.current,
    start: "top top",
    end: () => `+=${distance}`,
    pin: true,
    scrub: 1,
    invalidateOnRefresh: true,
  },
});

```

Here, `start: "top top"` pins the wrapper when it reaches the viewport top. The `end` callback sets the scroll length equal to the horizontal travel distance, and `scrub: 1` keeps the animation synchronized with scroll velocity.

## Complete HorizontalPan Component

Below is the full reusable component directly from [`skills/taste-skill/SKILL.md`](https://github.com/Leonxlnx/taste-skill/blob/main/skills/taste-skill/SKILL.md) in the Leonxlnx/taste-skill repo.

```tsx
// 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);
  const track = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    if (reduce || !wrap.current || !track.current) return;

    const ctx = gsap.context(() => {
      const distance = track.current!.scrollWidth - window.innerWidth;
      gsap.to(track.current, {
        x: -distance,
        ease: "none",
        scrollTrigger: {
          trigger: wrap.current,
          start: "top top",
          end: () => `+=${distance}`,
          pin: true,
          scrub: 1,
          invalidateOnRefresh: true,
        },
      });
    }, wrap);

    return () => ctx.revert();
  }, [reduce]);

  return (
    <section ref={wrap} className="relative overflow-hidden">
      <div ref={track} className="flex h-[100dvh] items-center gap-8">
        {children}
      </div>
    </section>
  );
}

```

The `return () => ctx.revert()` line in the `useEffect` cleanup guarantees that every ScrollTrigger instance and tween is destroyed when the component unmounts or `reduce` changes.

## Using the Component with Content Cards

You can drop any child elements into the `HorizontalPan` component. Here is a practical page example using a row of cards:

```tsx
// src/app/page.tsx (or any client component)
import { HorizontalPan } from "@/components/HorizontalPan";

export default function Demo() {
  return (
    <HorizontalPan>
      {Array.from({ length: 6 }).map((_, i) => (
        <div
          key={i}
          className="w-[300px] h-[400px] bg-white rounded-xl shadow-lg flex items-center justify-center"
        >
          Card {i + 1}
        </div>
      ))}
    </HorizontalPan>
  );
}

```

When the page is scrolled, the wrapper stays pinned while the inner track slides left, producing the smooth horizontal-pan experience.

## Tailwind CSS Configuration

The component relies on standard Tailwind utility classes that match the repository's default Tailwind setup. The wrapper uses `relative overflow-hidden`, while the track uses `flex h-[100dvh] items-center gap-8` to maintain a full-viewport height flex container with evenly spaced children.

```html
<section class="relative overflow-hidden">
  <div class="flex h-[100dvh] items-center gap-8">
    <!-- child elements -->
  </div>
</section>

```

These utilities align with the Tailwind v4 configuration present in the repo's base [`tailwind.config.js`](https://github.com/Leonxlnx/taste-skill/blob/main/tailwind.config.js).

## Core Source Files

Understanding where these pieces live helps when extending the pattern:

- **[`skills/taste-skill/SKILL.md`](https://github.com/Leonxlnx/taste-skill/blob/main/skills/taste-skill/SKILL.md)** – Documents the Horizontal-Pan canonical skeleton and the GSAP/ScrollTrigger usage details.
- **[`README.md`](https://github.com/Leonxlnx/taste-skill/blob/main/README.md)** – Provides general installation steps and guidance on required dependencies such as `gsap` and `@gsap/scrolltrigger`.
- **[`package.json`](https://github.com/Leonxlnx/taste-skill/blob/main/package.json)** – Lists `gsap` and `motion` as runtime dependencies, confirming the libraries are available for the component.
- **`assets/`** – Contains example imagery that can be placed inside the `HorizontalPan` track for visual testing.

## Summary

- The **HorizontalPan** component is a client-only React component that requires the `"use client"` directive for browser-only execution.
- It calculates horizontal travel distance as **`track.current!.scrollWidth - window.innerWidth`**.
- **`gsap.context()`** scopes all animations to the wrapper and enables one-shot cleanup with **`ctx.revert()`**.
- **ScrollTrigger** pins the wrapper with **`pin: true`**, starts at **`"top top"`**, and scrubs the horizontal translation using **`scrub: 1`**.
- **`useReducedMotion()`** ensures the animation respects accessibility preferences by exiting early when reduced motion is requested.

## Frequently Asked Questions

### What does `invalidateOnRefresh: true` do in the GSAP ScrollTrigger setup?

It instructs ScrollTrigger to recalculate its measurements when the page refreshes or resizes. In the taste-skill implementation, this keeps the horizontal pan animation precise if the viewport width or track content changes after initial load.

### Why is the animation wrapped inside `gsap.context()`?

`gsap.context()` scopes every tween and trigger created inside its callback to the provided DOM node. Passing `wrap` as the scope means that calling `ctx.revert()` on component unmount automatically destroys every GSAP instance without manual cleanup logic.

### How does the component handle reduced motion preferences?

The component imports **`useReducedMotion`** from `motion/react`. If the hook returns `true`, the `useEffect` exits early before registering any GSAP tweens, ensuring users who prefer reduced motion do not experience forced scroll-driven animations.

### Is this pattern compatible with Next.js App Router?

Yes. The **`"use client"`** directive at the top of [`src/components/HorizontalPan.tsx`](https://github.com/Leonxlnx/taste-skill/blob/main/src/components/HorizontalPan.tsx) marks the boundary for client execution, making the component fully compatible with the Next.js App Router while preventing server-side rendering issues with browser-only globals like `window` and `document`.