# GSAP ScrollTrigger — Next.js

> **Scope:** frontend-nextjs-gsap
> **Layer:** 3
> **Keywords:** scrolltrigger, scroll-animation, pinning, scrubbing, parallax, scroll-driven, gsap
> **Load When:** ui-designer active, or the project animates with GSAP

**Verified against:** GSAP 3.13 ScrollTrigger + @gsap/react. Last-verified: 2026-05-20.

---

Reference guide for implementing scroll-driven animations in Next.js projects using GSAP ScrollTrigger. Covers plugin setup, scrubbing, pinning, batch processing, responsive breakpoints, SSR considerations, and mandatory cleanup patterns.

**Pair this with a smooth scroller before shipping a cinematic page.** ScrollTrigger reads native scroll on its own, which is correct for app UI. A landing page whose scroll drives a scene wants Lenis handing scroll to ScrollTrigger through the GSAP ticker, plus the instance exposed on `window` so overlays and automated verification can drive it. That wiring, and the traps that come with it, live in `frontend/scroll-driven/smooth-scroll.md`.

---

## Section 1: Plugin Registration

```typescript
'use client';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);
```

Register ONCE at app init. In Next.js, do this in a client-side providers file or at the top of client components. Never register inside `useEffect` or loops — repeated registration is wasted work and can cause subtle timing issues.

---

## Section 2: Basic ScrollTrigger

The fundamental pattern: attach a `scrollTrigger` object to any GSAP tween.

```typescript
useGSAP(() => {
  gsap.from('.card', {
    y: 60,
    opacity: 0,
    duration: 0.8,
    ease: 'power2.out',
    stagger: 0.15,
    scrollTrigger: {
      trigger: '.cards-container',
      start: 'top 80%',    // trigger-top hits viewport-80%
      end: 'bottom 20%',
      toggleActions: 'play none none none', // onEnter onLeave onEnterBack onLeaveBack
      markers: false,       // true for debugging only
    },
  });
}, { scope: containerRef });
```

**start/end syntax:** `"triggerPosition viewportPosition"`.

| Value | Meaning |
|-------|---------|
| `"top center"` | Trigger element's top reaches viewport center |
| `"top 80%"` | Trigger element's top reaches 80% down the viewport |
| `"top top"` | Trigger element's top reaches viewport top |
| `"bottom top"` | Trigger element's bottom reaches viewport top |

**toggleActions** accepts four space-separated values for `onEnter`, `onLeave`, `onEnterBack`, `onLeaveBack`. Each value can be: `play`, `pause`, `resume`, `reverse`, `restart`, `reset`, `complete`, `none`.

---

## Section 3: Scrubbing

Link animation progress directly to scroll position instead of playing on a timeline.

```typescript
gsap.to('.parallax-bg', {
  y: -200,
  ease: 'none', // linear for scrub
  scrollTrigger: {
    trigger: '.hero-section',
    start: 'top top',
    end: 'bottom top',
    scrub: true,        // direct link (can feel jumpy)
    // scrub: 0.5,      // smooth scrub with 0.5s delay
  },
});
```

**Rule:** Use `ease: "none"` with scrub for linear scroll-to-progress mapping. Non-linear eases with scrub cause unexpected acceleration/deceleration that fights the user's scroll intent.

- `scrub: true` — instant 1:1 link between scroll and progress.
- `scrub: 0.5` — smoothed with 0.5 second catch-up delay. Use values between 0.3 and 1 for production.
- `scrub: 3` — heavy smoothing, good for background parallax layers.

---

## Section 4: Pinning

Pin an element in place while the user scrolls through content.

```typescript
gsap.to('.pinned-content', {
  x: '-300%', // horizontal scroll simulation
  ease: 'none',
  scrollTrigger: {
    trigger: '.horizontal-section',
    start: 'top top',
    end: '+=3000',        // pin for 3000px of scroll
    pin: true,
    scrub: 1,
    anticipatePin: 1,     // prevents jank on pin start
    // pinSpacing: true,  // default: adds space for pinned section
  },
});
```

**Horizontal scroll pattern:** Pin the outer container, then translate an inner track of panels by `-100% * (panelCount - 1)` using scrub. The `end: "+=Npx"` controls how much vertical scroll maps to the horizontal distance.

```typescript
useGSAP(() => {
  const panels = gsap.utils.toArray<HTMLElement>('.panel');

  gsap.to(panels, {
    xPercent: -100 * (panels.length - 1),
    ease: 'none',
    scrollTrigger: {
      trigger: '.horizontal-wrapper',
      start: 'top top',
      end: () => `+=${panels.length * window.innerWidth}`,
      pin: true,
      scrub: 1,
      snap: 1 / (panels.length - 1),
      anticipatePin: 1,
    },
  });
}, { scope: containerRef });
```

**Pinning rules:**
- Set `anticipatePin: 1` to avoid visual jank when pinning starts.
- `pinSpacing: false` prevents the extra space insertion — use only when the pinned section overlaps subsequent content intentionally.
- Never nest pinned elements inside other pinned elements.

---

## Section 5: Batch Processing

For staggered reveals on lists and grids, use `ScrollTrigger.batch` instead of individual triggers.

```typescript
ScrollTrigger.batch('.grid-item', {
  onEnter: (elements) => {
    gsap.from(elements, {
      y: 40,
      opacity: 0,
      stagger: 0.08,
      duration: 0.6,
      ease: 'power2.out',
    });
  },
  start: 'top 85%',
});
```

Use batch instead of individual ScrollTriggers for performance — one IntersectionObserver watches all matched elements instead of creating dozens of separate scroll listeners. This matters significantly for grids with 20+ items.

Batch also supports `onLeave`, `onEnterBack`, and `onLeaveBack` callbacks with the same signature.

---

## Section 6: Scroll-linked Progress

Use `onUpdate` for custom progress-based effects that go beyond simple tweens.

```typescript
ScrollTrigger.create({
  trigger: '.progress-section',
  start: 'top center',
  end: 'bottom center',
  onUpdate: (self) => {
    // self.progress is 0-1
    gsap.set('.progress-bar', { scaleX: self.progress });
    gsap.set('.counter', { innerText: Math.round(self.progress * 100) + '%' });
  },
});
```

**Parallax layers pattern:** Assign different scroll speeds to layered elements using a `data-depth` attribute.

```typescript
gsap.utils.toArray<HTMLElement>('.parallax-layer').forEach((layer) => {
  const depth = parseFloat(layer.dataset.depth || '0.5');
  gsap.to(layer, {
    y: -200 * depth,
    ease: 'none',
    scrollTrigger: {
      trigger: layer.parentElement,
      start: 'top bottom',
      end: 'bottom top',
      scrub: true,
    },
  });
});
```

Layers with `data-depth="1"` move the full -200px, while `data-depth="0.2"` moves only -40px, creating natural depth.

---

## Section 7: Snap

Snap scrolling to discrete sections for a full-page slide experience.

```typescript
ScrollTrigger.create({
  trigger: '.sections-container',
  start: 'top top',
  end: 'bottom bottom',
  snap: {
    snapTo: 1 / (sectionCount - 1), // snap to each section
    duration: { min: 0.2, max: 0.6 },
    ease: 'power1.inOut',
  },
});
```

- `snapTo` accepts a number (interval), an array of progress values, or a function `(value) => snappedValue`.
- `duration` can be a fixed number or `{ min, max }` range — GSAP picks based on scroll velocity.
- Keep snap durations short (under 0.8s) to avoid fighting user intent.
- Combine with `pin: true` for pinned section snapping.

---

## Section 8: Responsive

Use `ScrollTrigger.matchMedia()` for breakpoint-aware animations.

```typescript
ScrollTrigger.matchMedia({
  // desktop
  '(min-width: 768px)': () => {
    gsap.to('.sidebar', {
      scrollTrigger: { trigger: '.content', pin: true, scrub: true },
      y: -100,
    });
  },
  // mobile — different or no animation
  '(max-width: 767px)': () => {
    gsap.set('.sidebar', { clearProps: 'all' });
  },
  // all: always active
  all: () => {
    // common setup shared across breakpoints
  },
});
```

Animations created inside each breakpoint callback are automatically reverted when the media query no longer matches, then re-created if the query matches again. This prevents stale transforms from desktop persisting on mobile after a resize.

**Rules:**
- Always define both desktop and mobile cases explicitly. Omitting mobile leaves desktop transforms applied.
- Place `matchMedia` inside `useGSAP` so it is cleaned up on unmount.
- Avoid overlapping breakpoint ranges — use exclusive boundaries (767px / 768px, not both at 768px).

---

## Section 9: Next.js SSR Considerations

ScrollTrigger relies on the DOM, `window`, and scroll position — it cannot run server-side.

**Mandatory rules for Next.js:**

1. All ScrollTrigger code must live in `'use client'` components.
2. Use the `useGSAP()` hook, which only executes client-side after mount. Never use bare `useEffect` for GSAP unless wrapping in `gsap.context()`.
3. Call `ScrollTrigger.refresh()` after any event that changes layout dimensions:
   - Dynamic content loads (API data arriving, skeleton replaced with real content)
   - Layout shifts (accordion opens, tab changes, collapsible sections)
   - Route changes in Next.js App Router
   - Images finishing load
4. For Next.js Image components, use the `onLoad` callback to trigger a refresh:

```typescript
<Image
  src="/hero.jpg"
  alt="Hero"
  onLoad={() => ScrollTrigger.refresh()}
/>
```

5. Use `ScrollTrigger.normalizeScroll(true)` for consistent cross-browser scroll behavior. This is particularly useful on iOS Safari where elastic bouncing and address bar resizing interfere with scroll calculations.
6. Avoid `ScrollTrigger.create()` at module scope — always place it inside `useGSAP` or a `useEffect` to guarantee the DOM exists.

---

## Section 10: Cleanup

```typescript
// Automatic with useGSAP — kills all animations and ScrollTriggers created in scope
useGSAP(() => {
  // all ScrollTriggers here are auto-cleaned on unmount
}, { scope: containerRef });

// Manual cleanup (if not using useGSAP):
useEffect(() => {
  const ctx = gsap.context(() => {
    // animations + ScrollTriggers
  }, containerRef);
  return () => ctx.revert(); // kills everything
}, []);
```

**CRITICAL:** Always clean up ScrollTriggers on unmount. Orphaned ScrollTriggers cause memory leaks, stale callbacks, and broken scroll behavior on subsequent route navigations.

**Refresh priority** for ordered triggers — higher priority triggers refresh first:

```typescript
ScrollTrigger.create({ trigger: '.first', refreshPriority: 1 });
ScrollTrigger.create({ trigger: '.second', refreshPriority: -1 });
```

Use `refreshPriority` when one trigger's position depends on another's pinning or layout effect. The default priority is 0; higher values refresh first.

---

*MORPH-SPEC by Polymorphism Tech*
