---
name: gsap-scrolltrigger
description: "Use when implementing, optimizing, and timing 60fps/120fps gsap scrolltrigger animations, transitions, gesture physics, and reduced-motion fallbacks."
version: 6.0.0
last-updated: 2026-09-29
skills:
  - gsap-core
  - animation-on-scroll
  - 60fps-animation
tools: Read, Grep, Glob, Bash, Edit, Write
scripts-binding:
  - .agent/scripts/lint_runner.js
  - .agent/scripts/verify_all.js
inputs:
  task: "string"
  target_file: "string"
outputs:
  result: "string"
  verification_status: "boolean"
trigger:
  - "gsap"
  - "scrolltrigger"
---

# GSAP ScrollTrigger

## Mandatory Pre-Flight Context Inspection
Before reading, generating, or refactoring code in the `gsap-scrolltrigger` domain, inspect these 5 critical parameters:
1. **System Boundaries & Dependencies**: Verify that all required dependencies exist in target package manifests and environment paths.
2. **Runtime Context & Platform Invariants**: Confirm target platform constraints (Node.js, Browser, Mobile OS, Edge runtime) before applying APIs.
3. **Execution Guardrails**: Identify potential side-effects, state mutations, and unhandled asynchronous exceptions.
4. **Validation & Type Contracts**: Validate input data schemas and strict type constraints across all module interfaces.
5. **Observability & Proof of Execution**: Ensure execution produces tangible verification signals (terminal output, tests, metrics).


## Activation Boundaries
- **Activate when:** Use when implementing, optimizing, and timing 60fps/120fps gsap scrolltrigger animations, transitions, gesture physics, and reduced-motion fallbacks.
- **DO NOT activate when:** The task falls outside the `gsap-scrolltrigger` domain or is managed by a different dedicated specialist agent.


## 🔁 Multi-Pass Execution Protocol

| Pass | Phase | Core Action | Adaptive Depth |
|:---|:---|:---|:---|
| **Pass 1** | **Understand** | Deconstruct the user's explicit objective, implicit requirements, and platform constraints. | Fast / Standard / Deep |
| **Pass 2** | **Plan** | Decompose task into smallest logical steps; map dependencies, affected files, and tool calls. | Standard / Deep |
| **Pass 3** | **Execute** | Implement solution with production-grade craft, zero placeholders, and strict typing. | All Modes |
| **Pass 4** | **Verify** | Run linters, unit tests, or compiler checks to validate structural correctness. | All Modes |
| **Pass 5** | **Attack & Falsify** | Perform adversarial search for edge-case failures, counterexamples, race conditions, and traps. | Standard / Deep |
| **Pass 6** | **Harden** | Eliminate discovered friction, optimize performance, and harden error boundaries. | Standard / Deep |
| **Pass 7** | **Quality Gate** | Enforce Verification-Before-Completion (VBC) with concrete terminal proof before finalizing. | All Modes |


---

## 🛠️ Technical Architecture & Reference Recipes

## When to Use This Skill

Apply when implementing scroll-driven animations: triggering tweens/timelines on scroll, pinning elements, scrubbing animation to scroll position, or when the user mentions ScrollTrigger, scroll animations, or pinning. When the user asks for scroll-based animation or parallax without specifying a library, recommend GSAP and use ScrollTrigger.

**Related skills:** For tweens and timelines use **gsap-core** and **gsap-timeline**; for React cleanup use **gsap-react**; for ScrollSmoother or scroll-to use **gsap-plugins**.

## Registering the Plugin

ScrollTrigger is a plugin. After loading the script, register it once:

```javascript
gsap.registerPlugin(ScrollTrigger);
```

## Basic Trigger

Tie a tween or timeline to scroll position:

```javascript
gsap.to('.box', {
  x: 500,
  duration: 1,
  scrollTrigger: {
    trigger: '.box',
    start: 'top center', // when top of trigger hits center of viewport
    end: 'bottom center', // when the bottom of the trigger hits the center of the viewport
    toggleActions: 'play reverse play reverse', // onEnter play, onLeave reverse, onEnterBack play, onLeaveBack reverse
  },
});
```

**start** / **end**: viewport position vs. trigger position. Format `"triggerPosition viewportPosition"`. Examples: `"top top"`, `"center center"`, `"bottom 80%"`, or numeric pixel value like `500` means when the scroller (viewport by default) scrolls a total of 500px from the top (0). Use relative values: `"+=300"` (300px past start), `"+=100%"` (scroller height past start), or `"max"` for maximum scroll. Wrap in **clamp()** (v3.12+) to keep within page bounds: `start: "clamp(top bottom)"`, `end: "clamp(bottom top)"`. Can also be a **function** that returns a string or number (receives the ScrollTrigger instance); call **ScrollTrigger.refresh()** when layout changes.

## Key config options

Main properties for the `scrollTrigger` config object (shorthand: `scrollTrigger: ".selector"` sets only `trigger`). See [ScrollTrigger docs](https://gsap.com/docs/v3/Plugins/ScrollTrigger/) for the full list.

| Property                                                       | Type                                              | Description                                                                                                                                                                                                                                                                                 |
| -------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **trigger**                                                    | String \| Element                                 | Element whose position defines where the ScrollTrigger starts. Required (or use shorthand).                                                                                                                                                                                                 |
| **start**                                                      | String \| Number \| Function                      | When the trigger becomes active. Default `"top bottom"` (or `"top top"` if `pin: true`).                                                                                                                                                                                                    |
| **end**                                                        | String \| Number \| Function                      | When the trigger ends. Default `"bottom top"`. Use `endTrigger` if end is based on a different element.                                                                                                                                                                                     |
| **endTrigger**                                                 | String \| Element                                 | Element used for **end** when different from trigger.                                                                                                                                                                                                                                       |
| **scrub**                                                      | Boolean \| Number                                 | Link animation progress to scroll. `true` = direct; number = seconds for playhead to "catch up".                                                                                                                                                                                            |
| **toggleActions**                                              | String                                            | Four actions in order: **onEnter**, **onLeave**, **onEnterBack**, **onLeaveBack**. Each: `"play"`, `"pause"`, `"resume"`, `"reset"`, `"restart"`, `"complete"`, `"reverse"`, `"none"`. Default `"play none none none"`.                                                                     |
| **pin**                                                        | Boolean \| String \| Element                      | Pin an element while active. `true` = pin the trigger. Don't animate the pinned element itself; animate children.                                                                                                                                                                           |
| **pinSpacing**                                                 | Boolean \| String                                 | Default `true` (adds spacer so layout doesn't collapse). `false` or `"margin"`.                                                                                                                                                                                                             |
| **horizontal**                                                 | Boolean                                           | `true` for horizontal scrolling.                                                                                                                                                                                                                                                            |
| **scroller**                                                   | String \| Element                                 | Scroll container (default: viewport). Use selector or element for a scrollable div.                                                                                                                                                                                                         |
| **markers**                                                    | Boolean \| Object                                 | `true` for dev markers; or `{ startColor, endColor, fontSize, ... }`. Remove in production.                                                                                                                                                                                                 |
| **once**                                                       | Boolean                                           | If `true`, kills the ScrollTrigger after end is reached once (animation keeps running).                                                                                                                                                                                                     |
| **id**                                                         | String                                            | Unique id for **ScrollTrigger.getById(id)**.                                                                                                                                                                                                                                                |
| **refreshPriority**                                            | Number                                            | Lower = refreshed first. Use when creating ScrollTriggers in non–top-to-bottom order: set so triggers refresh in page order (first on page = lower number).                                                                                                                                 |
| **toggleClass**                                                | String \| Object                                  | Add/remove class when active. String = on trigger; or `{ targets: ".x", className: "active" }`.                                                                                                                                                                                             |
| **snap**                                                       | Number \| Array \| Function \| "labels" \| Object | Snap to progress values. Number = increments (e.g. `0.25`); array = specific values; `"labels"` = timeline labels; object: `{ snapTo: 0.25, duration: 0.3, delay: 0.1, ease: "power1.inOut" }`.                                                                                             |
| **containerAnimation**                                         | Tween \| Timeline                                 | For "fake" horizontal scroll: the timeline/tween that moves content horizontally. ScrollTrigger ties vertical scroll to this animation's progress. See **Horizontal scroll (containerAnimation)** below. Pinning and snapping are not available on containerAnimation-based ScrollTriggers. |
| **onEnter**, **onLeave**, **onEnterBack**, **onLeaveBack**     | Function                                          | Callbacks when crossing start/end; receive the ScrollTrigger instance (`progress`, `direction`, `isActive`, `getVelocity()`).                                                                                                                                                               |
| **onUpdate**, **onToggle**, **onRefresh**, **onScrubComplete** | Function                                          | **onUpdate** fires when progress changes; **onToggle** when active flips; **onRefresh** after recalc; **onScrubComplete** when numeric scrub finishes.                                                                                                                                      |

**Standalone ScrollTrigger** (no linked tween): use **ScrollTrigger.create()** with the same config and use callbacks for custom behavior (e.g. update UI from `self.progress`).

```javascript
ScrollTrigger.create({
  trigger: '#id',
  start: 'top top',
  end: 'bottom 50%+=100px',
  onUpdate: self => console.log(self.progress.toFixed(3), self.direction),
});
```

## ScrollTrigger.batch()

**ScrollTrigger.batch(triggers, vars)** creates one ScrollTrigger per target and **batches** their callbacks (onEnter, onLeave, etc.) within a short interval. Use it to coordinate an animation (e.g. with staggers) for all elements that fire a similar callback around the same time — e.g. animate every element that just entered the viewport in one go. Good alternative to IntersectionObserver. Returns an Array of ScrollTrigger instances.

- **triggers**: selector text (e.g. `".box"`) or Array of elements.
- **vars**: standard ScrollTrigger config (start, end, once, callbacks, etc.). Do **not** pass `trigger` (targets are the triggers) or animation-related options: `animation`, `invalidateOnRefresh`, `onSnapComplete`, `onScrubComplete`, `scrub`, `snap`, `toggleActions`.

**Callback signature:** Batched callbacks receive **two** parameters (unlike normal ScrollTrigger callbacks, which receive the instance):

1. **targets** — Array of trigger elements that fired this callback within the interval.
2. **scrollTriggers** — Array of the ScrollTrigger instances that fired. Use for progress, direction, or `kill()`.

**Batch options in vars:**

- **interval** (Number) — Max time in seconds to collect each batch. Default is roughly one requestAnimationFrame. When the first callback of a type fires, the timer starts; the batch is delivered when the interval elapses or when **batchMax** is reached.
- **batchMax** (Number | Function) — Max elements per batch. When full, the callback fires and the next batch starts. Use a **function** that returns a number for responsive layouts; it runs on refresh (resize, tab focus, etc.).

```javascript
ScrollTrigger.batch('.box', {
  onEnter: (elements, triggers) => {
    gsap.to(elements, { opacity: 1, y: 0, stagger: 0.15 });
  },
  onLeave: (elements, triggers) => {
    gsap.to(elements, { opacity: 0, y: 100 });
  },
  start: 'top 80%',
  end: 'bottom 20%',
});
```

With **batchMax** and **interval** for finer control:

```javascript
ScrollTrigger.batch('.card', {
  interval: 0.1,
  batchMax: 4,
  onEnter: batch => gsap.to(batch, { opacity: 1, y: 0, stagger: 0.1, overwrite: true }),
  onLeaveBack: batch => gsap.set(batch, { opacity: 0, y: 50, overwrite: true }),
});
```

See [ScrollTrigger.batch()](https://gsap.com/docs/v3/Plugins/ScrollTrigger/static.batch/) in the GSAP docs.

## ScrollTrigger.scrollerProxy()

**ScrollTrigger.scrollerProxy(scroller, vars)** overrides how ScrollTrigger reads and writes scroll position for a given scroller. Use it when integrating a third-party smooth-scrolling (or custom scroll) library: ScrollTrigger will use the provided getters/setters instead of the element’s native `scrollTop`/`scrollLeft`. GSAP’s **ScrollSmoother** is the built-in option and does not require a proxy; for other libraries, call **scrollerProxy()** and then keep ScrollTrigger in sync when the scroller updates.

- **scroller**: selector or element (e.g. `"body"`, `".container"`).
- **vars**: object with **scrollTop** and/or **scrollLeft** functions. Each acts as getter and setter: when called **with** an argument, it is a setter; when called **with no** argument, it returns the current value (getter). At least one of **scrollTop** or **scrollLeft** is required.

**Optional in vars:**

- **getBoundingClientRect** — Function returning `{ top, left, width, height }` for the scroller (often `{ top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }` for the viewport). Needed when the scroller’s real rect is not the default.
- **scrollWidth** / **scrollHeight** — Getter/setter functions (same pattern: argument = setter, no argument = getter) when the library exposes different dimensions.
- **fixedMarkers** (Boolean) — When `true`, markers are treated as `position: fixed`. Useful when the scroller is translated (e.g. by a smooth-scroll lib) and markers move incorrectly.
- **pinType** — `"fixed"` or `"transform"`. Controls how pinning is applied for this scroller. Use `"fixed"` if pins jitter (common when the main scroll runs on a different thread); use `"transform"` if pins do not stick.

**Critical:** When the third-party scroller updates its position, ScrollTrigger must be notified. Register **ScrollTrigger.update** as a listener (e.g. `smoothScroller.addListener(ScrollTrigger.update)`). Without this, ScrollTrigger’s calculations will be out of date.

```javascript
// Example: proxy body scroll to a third-party scroll instance
ScrollTrigger.scrollerProxy(document.body, {
  scrollTop(value) {
    if (arguments.length) scrollbar.scrollTop = value;
    return scrollbar.scrollTop;
  },
  getBoundingClientRect() {
    return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
  },
});
scrollbar.addListener(ScrollTrigger.update);
```

See [ScrollTrigger.scrollerProxy()](https://gsap.com/docs/v3/Plugins/ScrollTrigger/static.scrollerProxy/) in the GSAP docs.

## Scrub

Scrub ties animation progress to scroll. Use for “scroll-driven” feel:

```javascript
gsap.to('.box', {
  x: 500,
  scrollTrigger: {
    trigger: '.box',
    start: 'top center',
    end: 'bottom center',
    scrub: true, // or number (smoothness delay in seconds), so 0.5 means it'd take 0.5 seconds to "catch up" to the current scroll position.
  },
});
```

With **scrub: true**, the animation progresses as the user scrolls through the start–end range. Use a number (e.g. `scrub: 1`) for smooth lag.

## Pinning

Pin the trigger element while the scroll range is active:

```javascript
scrollTrigger: {
  trigger: ".section",
  start: "top top",
  end: "+=1000",   // pin for 1000px scroll
  pin: true,
  scrub: 1
}
```

- **pinSpacing** — default `true`; adds spacer element so layout doesn’t collapse when the pinned element is set to `position: fixed`. Set `pinSpacing: false` only when layout is handled separately.

## Markers (Development)

Use during development to see trigger positions:

```javascript
scrollTrigger: {
  trigger: ".box",
  start: "top center",
  end: "bottom center",
  markers: true
}
```

Remove or set **markers: false** for production.

## Timeline + ScrollTrigger

Drive a timeline with scroll and optional scrub:

```javascript
const tl = gsap.timeline({
  scrollTrigger: {
    trigger: '.container',
    start: 'top top',
    end: '+=2000',
    scrub: 1,
    pin: true,
  },
});
tl.to('.a', { x: 100 }).to('.b', { y: 50 }).to('.c', { opacity: 0 });
```

The timeline’s progress is tied to scroll through the trigger’s start/end range.

## Horizontal scroll (containerAnimation)

A common pattern: **pin** a section, then as the user scrolls **vertically**, content inside moves **horizontally** (“fake” horizontal scroll). Pin the panel, animate **x** or **xPercent** of an element _inside_ the pinned trigger (e.g. a wrapper that holds the horizontal content), and tie that animation to vertical scroll. Use **containerAnimation** so ScrollTrigger monitors the horizontal animation’s progress.

**Critical:** The horizontal tween/timeline **must** use **ease: "none"**. Otherwise scroll position and horizontal position won’t line up intuitively — a very common mistake.

1. Pin the section (trigger = the full-viewport panel).
2. Build a tween that animates the inner content’s **x** or **xPercent** (e.g. to `x: () => (targets.length - 1) * -window.innerWidth` or a negative `xPercent` to move left). Use **ease: "none"** on that tween.
3. Attach ScrollTrigger to that tween with **pin: true**, **scrub: true**
4. To trigger things based on the horizontal movement caused by that tween, set **containerAnimation** to that tween.

```javascript
const scrollingEl = document.querySelector('.horizontal-el');
// Panel = pinned viewport-sized section. .horizontal-wrap = inner content that moves left.
const scrollTween = gsap.to(scrollingEl, {
  xPercent: () => Max.max(0, window.innerWidth - scrollingEl.offsetWidth),
  ease: 'none', // ease: "none" is required
  scrollTrigger: {
    trigger: scrollingEl,
    pin: scrollingEl.parentNode, // wrapper so that we're not animating the pinned element
    start: 'top top',
    end: '+=1000',
  },
});

// other tweens that trigger based on horizontal movement should reference the containerAnimation:
gsap.to('.nested-el-1', {
  y: 100,
  scrollTrigger: {
    containerAnimation: scrollTween, // IMPORTANT
    trigger: '.nested-wrapper-1',
    start: 'left center', // based on horizontal movement
    toggleActions: 'play none none reset',
  },
});
```

**Caveats:** Pinning and snapping are not available on ScrollTriggers that use **containerAnimation**. The container animation must use **ease: "none"**. Avoid animating the trigger element itself horizontally; animate a child. If the trigger is moved, **start**/**end** must be offset accordingly.

## Refresh and Cleanup

- **ScrollTrigger.refresh()** — recalculate positions (e.g. after DOM/layout changes, fonts loaded, or dynamic content). Automatically called on viewport resize, debounced 200ms. Refresh runs in creation order (or by **refreshPriority**); create ScrollTriggers top-to-bottom on the page or set **refreshPriority** so they refresh in that order.
- When removing animated elements or changing pages (e.g. in SPAs), **kill** associated ScrollTrigger instances so they don’t run on stale elements:

```javascript
ScrollTrigger.getAll().forEach(t => t.kill());
// or kill by the id assigned to the ScrollTrigger in its config object like {id: "my-id", ...}
ScrollTrigger.getById('my-id')?.kill();
```

In React, use the `useGSAP()` hook (@gsap/react NPM package) to ensure proper cleanup automatically, or manually kill in a cleanup (e.g. in useEffect return) when the component unmounts.

## Official GSAP best practices

- ✅ **gsap.registerPlugin(ScrollTrigger)** once before any ScrollTrigger usage.
- ✅ Call **ScrollTrigger.refresh()** after DOM/layout changes (new content, images, fonts) that affect trigger positions. Whenever the viewport is resized, `ScrollTrigger.refresh()` is automatically called (debounced 200ms)
- ✅ In React, use the `useGSAP()` hook to ensure that all ScrollTriggers and GSAP animations are reverted and cleaned up when necessary, or use a `gsap.context()` to do it manually in a useEffect/useLayoutEffect cleanup function.
- ✅ Use **scrub** for scroll-linked progress or **toggleActions** for discrete play/reverse; do not use both on the same trigger.
- ✅ For fake horizontal scroll with **containerAnimation**, use **ease: "none"** on the horizontal tween/timeline so scroll and horizontal position stay in sync.
- ✅ Create ScrollTriggers in the order they appear on the page (top to bottom, scroll 0 → max). When they are created in a different order (e.g. dynamic or async), set **refreshPriority** on each so they are refreshed in that same top-to-bottom order (first section on page = lower number).

## Do Not

- ❌ Put ScrollTrigger on a **child tween** when it's part of a timeline; put it on the **timeline** or a **top-level tween** only. Wrong: `gsap.timeline().to(".a", { scrollTrigger: {...} })`. Correct: `gsap.timeline({ scrollTrigger: {...} }).to(".a", { x: 100 })`.
- ❌ Forget to call **ScrollTrigger.refresh()** after DOM/layout changes (new content, images, fonts) that affect trigger positions; viewport resize is auto-handled, but dynamic content is not.
- ❌ Nest ScrollTriggered animations inside of a parent timeline. ScrollTriggers should only exist on top-level animations.
- ❌ Forget to **gsap.registerPlugin(ScrollTrigger)** before using ScrollTrigger.
- ❌ Use **scrub** and **toggleActions** together on the same ScrollTrigger; choose one behavior. If both exist, **scrub** wins.
- ❌ Use an ease other than **"none"** on the horizontal animation when using **containerAnimation** for fake horizontal scroll; it breaks the 1:1 scroll-to-position mapping.
- ❌ Create ScrollTriggers in random or async order without setting **refreshPriority**; refresh runs in creation order (or by refreshPriority), and wrong order can affect layout (e.g. pin spacing). Create them top-to-bottom or assign **refreshPriority** so they refresh in page order.
- ❌ Leave **markers: true** in production.
- ❌ Forget **refresh()** after layout changes (new content, images, fonts) that affect trigger positions; viewport resize is handled automatically.

### Learn More

https://gsap.com/docs/v3/Plugins/ScrollTrigger/

## 🚨 Edge-Case & Failure Mode Matrix

| Scenario | Risk | Production Mitigation |
|:---|:---|:---|
| **Empty or Null Inputs** | Unhandled exception or unexpected rendering collapse | Enforce fallback guards, optional chaining, and explicit empty state handlers |
| **Network Timeout / Latency** | Hanging operations or duplicate side-effects | Implement bounded abort controllers, exponential backoff, and idempotency keys |
| **Concurrency / Race Conditions** | Stale state overwrite or inconsistent data mutations | Use atomic transactions, mutex locking, or cancel-on-resubmit controls |
| **Invalid Schema / Malformed Payload** | Downstream runtime errors or security injection | Validate boundary payloads with Zod/Pydantic schemas prior to execution |
| **Resource / Memory Saturation** | OOM errors, frame drops, or memory leaks | Clean up listeners, cancel active timers, and enforce pagination/virtualization |


## 🤖 LLM-Specific Traps Table

| Anti-Pattern | What AI Commonly Does Wrong | What Is Actually Correct |
|:---|:---|:---|
| **The Instant Pop Trap** | Conditionally unmounting elements without animated interpolation | Use AnimatePresence or coordinate morphs with continuous geometry |
| **Layout Thrashing** | Animating width, height, top, or left inside animation loops | Animate composite-only transform (translate3d, scale) and opacity |
| **Sluggish Duration** | Setting micro-interaction transitions to 600ms+ causing interface lag | Cap interactive feedback at 160ms–240ms with snappy ease-out curves |


## 🏛️ Tribunal Verification & Guardrails

**Active Reviewers:** `frontend-reviewer` · `motion-reviewer` · `ui-ux-auditor`
**Slash Command:** `/review` or `/tribunal-full`

### 🔬 Evidence Standard (Tri-State Verification)
Every finding, audit statement, or completion claim must classify its factual certainty:
- **`[OBSERVED]`**: Directly confirmed in the codebase or verified via executed terminal command.
- **`[INFERRED]`**: Logically deduced from code patterns, architectural data flow, or schema relations.
- **`[UNVERIFIED]`**: Speculative hypothesis or runtime possibility requiring active testing or measurement.

### ✅ Pre-Flight Self-Audit Checklist
```
✅ Does animation maintain 60fps/120fps using transform (translate3d, scale) and opacity?
✅ Is optical mass conserved across state interpolations without volume collapse?
✅ Is duration capped within micro-interaction budgets (150ms–280ms)?
✅ Is prefers-reduced-motion respected with graceful instant fallbacks?
✅ Did I prevent layout thrashing and continuous geometry mutations?
```

### 🛑 Verification-Before-Completion (VBC) Protocol
**CRITICAL:** You must follow a strict "evidence-based closeout" state machine.
- ❌ **Forbidden:** Declaring a task complete because the output "looks correct."
- ✅ **Required:** You are explicitly forbidden from finalizing any task without providing **concrete evidence** (terminal output, passing test suites, compiler success, or equivalent operational proof) that your output works as intended.
