Add web animation skills (iart-ai/web-animation-skills)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeokZtSmqg42ZGWsLXX2C
This commit is contained in:
Claude committed 2026-10-18 21:51:39 +00:00
1 parent 0b42fdeae1
commit 59c7859bd4
42 files changed
+4170

No files matched your search

View File
Whitespace-only changes.
+225
View File
@@ -0,0 +1,225 @@
---
name: gsap-web
description: This skill should be used when the user asks to "build a scroll animation", "add ScrollTrigger", "pin a section while scrolling", "scrub an animation to scroll", "create a hero timeline", "do a horizontal scroll section", "split text and animate words", "animate a layout change with GSAP Flip", "sync ScrollTrigger with Lenis", "Lenis smooth scroll jittery", "ScrollTrigger markers drift", "ScrollTrigger pins break with Lenis/Locomotive", or "set up smooth scroll with GSAP pinning". Covers GSAP timelines, ScrollTrigger, SplitText, Flip, and Lenis/Locomotive smooth-scroll sync for code-driven web motion.
version: 0.1.0
---
# GSAP for the Web
GSAP (GreenSock Animation Platform) is the workhorse for code-driven web motion: sequenced timelines, scroll-driven storytelling, text reveals, and layout transitions. As of GSAP 3.12+, every plugin (ScrollTrigger, SplitText, Flip, MotionPath, MorphSVG, Draggable, Observer) is 100% free, including for commercial use.
## When to use
- Scroll-driven storytelling: pinned sections, parallax, progress scrubbing, horizontal scroll
- Sequenced hero animations and complex multi-element timelines with precise overlap control
- Text reveals split into chars/words/lines (SplitText)
- Layout transitions where an element changes position/size/parent (Flip)
- Any motion needing fine timing control, easing precision, or imperative orchestration
## Install and register
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { Flip } from "gsap/Flip";
gsap.registerPlugin(ScrollTrigger, SplitText, Flip);
```
Plugins MUST be registered before use or they silently no-op. With a bundler, register once at app entry.
## Core techniques
### Timelines first
Prefer one timeline over many independent tweens — it gives a single playhead, relative positioning, and easy reversal.
```js
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } });
tl.from(".title", { yPercent: 100, opacity: 0 })
.from(".sub", { y: 20, opacity: 0 }, "-=0.3") // start 0.3s before prev ends
.from(".cta", { scale: 0.9, opacity: 0 }, "<"); // align to prev tween START
```
Position parameter cheat sheet:
- `"+=0.5"` / `"-=0.3"` — relative to the end of the timeline (gap / overlap)
- `"<"` — start of the previous tween; `">"` — end of the previous tween
- `"<0.2"` — 0.2s after the previous tween's start
- `"myLabel"` — at a named label added via `tl.addLabel("myLabel")`
Use `.from()` for entrances (animates FROM the given values TO current CSS), `.to()` for exits, `.fromTo()` when both ends must be explicit (most robust against re-runs).
### gsap.to vs set vs quickTo
```js
gsap.set(el, { autoAlpha: 0 }); // instant, no tween (autoAlpha = opacity + visibility)
const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3" });
window.addEventListener("pointermove", (e) => xTo(e.clientX)); // fast repeated updates
```
`autoAlpha` is preferred over raw `opacity` because it also toggles `visibility:hidden` at 0, removing the element from hit-testing.
### ScrollTrigger basics
```js
gsap.to(".panel", {
xPercent: -100,
ease: "none",
scrollTrigger: {
trigger: ".wrap",
start: "top top", // when trigger top hits viewport top
end: "+=2000", // 2000px of scroll distance
pin: true, // freeze .wrap while the tween plays
scrub: 1, // tie progress to scrollbar (1 = 1s catch-up smoothing)
markers: true, // dev-only visual markers — remove for prod
},
});
```
Key semantics:
- `start`/`end` take `"triggerPos viewportPos"` (e.g. `"top center"`) or `"+=px"`.
- `scrub: true` locks animation progress to scroll exactly; `scrub: <number>` adds smoothing lag.
- `toggleActions: "play pause resume reverse"` controls onEnter/onLeave/onEnterBack/onLeaveBack for non-scrubbed triggers.
- Use `ease: "none"` for scrubbed tweens so motion tracks scroll linearly.
### Smooth scroll (Lenis) + ScrollTrigger
Pairing Lenis (or Locomotive) smooth scrolling with ScrollTrigger desyncs unless both run on one loop. Lenis smooths scroll on its own rAF while ScrollTrigger reads scroll on GSAP's ticker; when they tick independently ScrollTrigger samples a stale position. The fix: drive Lenis from GSAP's ticker and update ScrollTrigger on every Lenis scroll.
```js
import Lenis from "lenis";
const lenis = new Lenis({ duration: 1.2, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update); // 1. update ScrollTrigger on every Lenis scroll
gsap.ticker.add((t) => lenis.raf(t * 1000)); // 2. one loop: ticker drives Lenis (seconds → ms)
gsap.ticker.lagSmoothing(0); // 3. stop GSAP catch-up jitter on heavy frames
```
Critical: `gsap.ticker` passes time in **seconds**, `lenis.raf()` wants **milliseconds** — multiply by 1000. Do NOT also run a standalone `requestAnimationFrame(raf)` loop for Lenis; that double-drives it.
Common bugs:
- **Jitter/stutter** — a leftover `requestAnimationFrame(raf)` loop competing with the ticker, or missing `lagSmoothing(0)`. Remove the rogue loop; set lag smoothing to 0.
- **Markers drift** from their triggers — `ScrollTrigger.update` is not subscribed to `lenis.on("scroll", …)`.
- **Pins break** — under Lenis do NOT set a `scrollerProxy` (it scrolls the document). Under Locomotive you MUST wire `scrollerProxy` + `pinType` and set `scroller:` on every trigger.
- **Scroll stuck / flipped direction** — missing the `* 1000` unit conversion, or two loops out of order.
Cleanup on SPA unmount: `gsap.ticker.remove(update)` + `lenis.destroy()`, else loops stack per navigation. Gate behind `prefers-reduced-motion` (skip Lenis, run native scroll). Full Lenis + Locomotive wiring, anchor-link routing, `data-lenis-prevent`, React (`useGSAP`/`useEffect`), and a symptom→cause table are in `references/scrolltrigger-lenis.md`.
### SplitText (text reveal)
```js
const split = SplitText.create(".headline", { type: "lines, words", linesClass: "line" });
gsap.from(split.lines, { yPercent: 100, opacity: 0, stagger: 0.08, duration: 0.7, ease: "power4.out" });
```
Gotchas:
- Wrap line-mask reveals: set `overflow: hidden` on the line wrapper so `yPercent: 100` hides cleanly. Add `autoSplit: true` (GSAP 3.13+) to re-split on font load / resize.
- Call `split.revert()` before re-splitting or on unmount to restore original DOM and avoid duplicated nodes.
- Always split AFTER web fonts load (`document.fonts.ready.then(...)`) to prevent wrong line breaks.
### Flip (layout transitions)
Flip records state, lets the DOM change instantly, then animates the visual difference (FLIP technique). Ideal for grid<->list, expanding cards, and reparenting.
```js
const state = Flip.getState(".item"); // 1. capture BEFORE
container.classList.toggle("grid"); // 2. mutate DOM/CSS (instant)
Flip.from(state, { // 3. animate the delta
duration: 0.6, ease: "power2.inOut", stagger: 0.05,
absolute: true, // take items out of flow during move (prevents reflow jitter)
});
```
### Easing quick guide
- `power2/3.out` — entrances (fast then settle)
- `power2.inOut` — moves between two on-screen states
- `back.out(1.7)` — overshoot/pop (number = overshoot amount)
- `elastic.out(1, 0.3)` — bouncy, use sparingly
- `none` — scrubbed scroll tweens
- `steps(n)` — sprite/stepped motion
- Custom cubic-bezier equivalent: `CustomEase.create("x", "M0,0 C0.2,0 0,1 1,1")` (CustomEase is free)
## Performance and cleanup
- Animate `transform` (`x/y/xPercent/scale/rotation`) and `opacity` only — they are GPU-composited and skip layout/paint. Avoid animating `top/left/width/height`.
- Set `will-change: transform` on heavy/pinned elements; remove after.
- Batch many similar scroll reveals with `ScrollTrigger.batch()` instead of one trigger per element.
- After a viewport/content change, call `ScrollTrigger.refresh()`.
SPA / framework cleanup is mandatory — orphaned triggers cause memory leaks and ghost pinning. Use `gsap.context()` (or `useGSAP` from `@gsap/react`):
```js
useEffect(() => {
const ctx = gsap.context(() => {
// all gsap + ScrollTrigger code here
}, rootRef);
return () => ctx.revert(); // kills tweens, triggers, and reverts inline styles
}, []);
```
## Deliver & verify (standalone HTML)
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
For a self-contained animation (hero, reveal, loop, micro-scene) the deliverable is **one HTML file that opens directly in a browser** — no build step, no framework, no render pipeline. Match the deliverable to the weight of the work: a single file is the right tier for web motion; don't reach for a bundler when one file does the job.
**Output contract:**
- One `.html` file: GSAP + plugins from CDN, your markup, and the animation in one inline `<script>`.
- All motion on **one master timeline** (`const tl = gsap.timeline()`) — a single playhead you can seek.
- Include the seek harness below so any moment can be frozen for inspection.
**Seek harness — freeze an exact moment for screenshots.** The web parallel of a video player's frame-pin: `?t=N` seeks the master timeline to `N` seconds and pauses, so a screenshot lands on a still, deterministic frame.
```html
<script>
// ... build your master timeline as `tl` ...
const t = new URLSearchParams(location.search).get("t");
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // frozen at t seconds
// no ?t → plays normally
window.__ready = true; // ready signal for headless wait
console.log("duration", tl.duration());
</script>
```
**Verify loop — render → freeze → screenshot → check:**
1. Open the file at three moments across the timeline — start, mid, end:
`…/anim.html?t=0`, `?t=<dur/2>`, `?t=<dur>`. Read `tl.duration()` from the console for the end time.
2. Screenshot each frozen frame.
3. Check both **fidelity** (does it match the brief?) and **artifacts** (clipped text, elements off-canvas, FOUC before fonts load, jank at seams). Output should look intentional and finished.
Any headless screenshot tool works — your agent's browser tool, or Playwright:
```bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=1.2" frame-mid.png
```
**Before you finish:**
1. Opens standalone in a browser — no console errors, no missing CDN.
2. All animation on one master timeline; `?t=N` freezes correctly.
3. Screenshotted at start / mid / end — matches the brief, no artifacts.
4. `prefers-reduced-motion` honored (timeline simplified or skipped).
5. Easing is intentional — no accidental `linear` on spatial motion.
A complete runnable template with the harness wired in is in `examples/standalone-template.html`.
## Quick reference
| Goal | API |
|------|-----|
| Sequence with overlap | `tl.from(...).from(..., "-=0.3")` |
| Pin while scrolling | `scrollTrigger:{ pin:true, end:"+=N" }` |
| Scrub to scroll | `scrollTrigger:{ scrub:1, ease:"none" }` |
| Reveal lines of text | `SplitText.create(el,{type:"lines"})` |
| Animate layout change | `Flip.getState` → mutate → `Flip.from` |
| Fast pointer follow | `gsap.quickTo(el,"x",{...})` |
| Kill everything | `ScrollTrigger.getAll().forEach(t=>t.kill())` |
## Reference files
- `references/scrolltrigger-cookbook.md` — pin, scrub, horizontal scroll, snap, `ScrollTrigger.batch()` reveals, parallax, nested triggers, and SPA cleanup patterns with full code.
- `references/scrolltrigger-lenis.md` — full ScrollTrigger + Lenis/Locomotive smooth-scroll sync: canonical wiring, `scrollerProxy`, anchor links, reduced-motion, React (`useGSAP`/`useEffect`), debugging checklist, and symptom→cause table.
- `examples/hero-timeline.js` — a complete, runnable hero entrance timeline with SplitText, staggered reveals, and reduced-motion handling.
- `examples/standalone-template.html` — the deliverable template: self-contained (CDN GSAP), one master timeline, the `?t=N` seek harness for screenshot verification, and reduced-motion handling.
@@ -0,0 +1,67 @@
/**
* hero-timeline.js — complete, runnable GSAP hero entrance.
*
* Markup expected:
* <section class="hero">
* <p class="hero__eyebrow">EYEBROW</p>
* <h1 class="hero__title">Big bold headline that wraps</h1>
* <p class="hero__sub">Supporting copy.</p>
* <div class="hero__actions"><a class="btn">Primary</a><a class="btn btn--ghost">Secondary</a></div>
* <img class="hero__art" src="art.svg" alt="" />
* </section>
*
* CSS prerequisite: each split line wrapper needs overflow:hidden for the mask reveal:
* .hero__title .line { overflow: hidden; }
*
* Install: npm i gsap @gsap/react
*/
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
export function playHeroIntro(root = document) {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const q = gsap.utils.selector(root);
// Reduced motion: reveal everything instantly, no animation.
if (reduceMotion) {
gsap.set(q(".hero__eyebrow, .hero__sub, .hero__actions, .hero__art"), { autoAlpha: 1 });
gsap.set(q(".hero__title"), { autoAlpha: 1 });
return Promise.resolve();
}
// Wait for fonts so SplitText computes correct line breaks.
return document.fonts.ready.then(() => {
const titleEl = q(".hero__title")[0];
const split = new SplitText(titleEl, {
type: "lines",
linesClass: "line",
autoSplit: true, // GSAP 3.13+: re-split on resize/font swap
});
// Hidden start state (prevents flash of unstyled content).
gsap.set(q(".hero__eyebrow, .hero__sub, .hero__actions, .hero__art"), { autoAlpha: 0 });
gsap.set(titleEl, { autoAlpha: 1 });
const tl = gsap.timeline({
defaults: { ease: "power3.out", duration: 0.8 },
onComplete: () => split.revert(), // restore clean DOM once done (optional)
});
tl.from(".hero__eyebrow", { y: 16, autoAlpha: 0, duration: 0.5 })
.from(split.lines, { yPercent: 110, stagger: 0.12, duration: 0.9, ease: "power4.out" }, "-=0.2")
.from(".hero__sub", { y: 20, autoAlpha: 0 }, "-=0.6")
.from(".hero__actions .btn", { y: 12, autoAlpha: 0, stagger: 0.1, duration: 0.5 }, "-=0.4")
.from(".hero__art", { scale: 0.92, autoAlpha: 0, ease: "back.out(1.4)", duration: 1.1 }, "<0.1");
return tl;
});
}
// Auto-run on DOM ready when imported directly in a page.
if (typeof window !== "undefined" && document.querySelector(".hero")) {
if (document.readyState !== "loading") playHeroIntro();
else document.addEventListener("DOMContentLoaded", () => playHeroIntro());
}
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>loading</title>
<!-- GSAP core from CDN — no build step. Add plugins the same way if needed:
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/ScrollTrigger.min.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js"></script>
<style>
:root { color-scheme: dark; }
* { margin: 0; box-sizing: border-box; }
body {
min-height: 100svh; display: grid; place-items: center;
background: radial-gradient(120% 120% at 50% 0%, #1b1f3b 0%, #0a0a12 60%);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: #f4f5ff; overflow: hidden;
}
.stage { text-align: center; padding: 6vmin; }
.title { font-size: clamp(2.5rem, 9vw, 6rem); font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
.sub { font-size: clamp(1rem, 3.2vw, 1.6rem); margin-top: 1.2rem; color: #aab; }
.cta { display: inline-block; margin-top: 2.4rem; padding: 0.9rem 1.8rem;
border-radius: 999px; background: #5b6cff; color: #fff; font-weight: 600; }
</style>
</head>
<body>
<main class="stage">
<h1 class="title">Ship it.</h1>
<p class="sub">A standalone, screenshot-verifiable GSAP scene.</p>
<a class="cta" href="#">Get started</a>
</main>
<script>
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
// ── One master timeline: a single playhead the harness can seek ──────────
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.7 } });
if (reduce) {
// Reduced motion: no spatial movement — just make the final state visible.
gsap.set([".title", ".sub", ".cta"], { opacity: 1, y: 0 });
} else {
tl.from(".title", { yPercent: 110, opacity: 0, ease: "power4.out", duration: 0.9 })
.from(".sub", { y: 24, opacity: 0 }, "-=0.45")
.from(".cta", { scale: 0.9, opacity: 0, ease: "back.out(1.7)" }, "-=0.3");
}
// ── Seek harness: ?t=N freezes the timeline at N seconds for a clean shot ─
const t = new URLSearchParams(location.search).get("t");
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // deterministic frozen frame
// no ?t → plays normally on load
// ── Ready signals for headless verification ──────────────────────────────
window.__ready = true;
document.title = "ready";
console.log("duration", tl.duration()); // end time for the ?t=<dur> shot
</script>
</body>
</html>
@@ -0,0 +1,181 @@
# ScrollTrigger Cookbook
Runnable patterns for GSAP 3.12+. Register first:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
```
## 1. Pin a section and scrub a timeline through it
The most common scrollytelling pattern: hold a section fixed and drive a multi-step timeline with scroll.
```js
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".story",
start: "top top",
end: "+=3000", // total scroll distance the pin lasts
pin: true,
scrub: 1,
anticipatePin: 1, // reduces a 1-frame jump when pinning fast scroll
},
});
tl.from(".story .step-1", { autoAlpha: 0, y: 60 })
.from(".story .step-2", { autoAlpha: 0, y: 60 })
.to(".story .bg", { scale: 1.2, ease: "none" }, 0); // runs across whole timeline
```
Tips:
- Each tween occupies an equal slice of the pinned distance unless you set durations/positions.
- `pinSpacing: true` (default) adds padding so content below does not jump. Set `false` only when overlaying.
- If the pinned element is inside a transformed/`overflow` ancestor, pinning breaks — pin a top-level element or set `pinReparent: true`.
## 2. Horizontal scroll (panels move sideways as the page scrolls vertically)
```html
<section class="h-wrap"><div class="track">
<div class="panel">1</div><div class="panel">2</div><div class="panel">3</div>
</div></section>
```
```css
.track { display: flex; width: max-content; }
.panel { width: 100vw; height: 100vh; flex: 0 0 100vw; }
```
```js
const track = document.querySelector(".track");
gsap.to(track, {
x: () => -(track.scrollWidth - window.innerWidth), // function = recalculated on refresh
ease: "none",
scrollTrigger: {
trigger: ".h-wrap",
pin: true,
scrub: 1,
end: () => "+=" + (track.scrollWidth - window.innerWidth),
invalidateOnRefresh: true, // re-run the x function on resize
},
});
```
`invalidateOnRefresh: true` plus function-based values is the correct way to stay responsive.
## 3. Snap to sections
```js
gsap.to(".sections", {
scrollTrigger: {
trigger: ".sections",
pin: true,
scrub: 1,
end: "+=4000",
snap: {
snapTo: 1 / 4, // 5 evenly spaced points (labels also possible: "labels")
duration: { min: 0.2, max: 0.6 },
delay: 0.05,
ease: "power1.inOut",
},
},
});
```
To snap to timeline labels: build a labeled timeline and use `snap: "labelsDirectional"`.
## 4. Batch reveals (performant many-element fade-in)
Instead of creating one ScrollTrigger per card (expensive at scale), batch them:
```js
ScrollTrigger.batch(".card", {
start: "top 85%",
onEnter: (els) =>
gsap.from(els, { autoAlpha: 0, y: 40, stagger: 0.12, overwrite: true }),
onLeaveBack: (els) =>
gsap.to(els, { autoAlpha: 0, y: 40, stagger: 0.12, overwrite: true }),
});
```
Set the initial hidden state to avoid a flash before JS runs: `gsap.set(".card", { autoAlpha: 0 })`.
## 5. Parallax layers
```js
gsap.utils.toArray(".parallax").forEach((layer) => {
const depth = layer.dataset.depth || 0.3; // 0 = static, 1 = scrolls with page
gsap.to(layer, {
yPercent: -depth * 100,
ease: "none",
scrollTrigger: { trigger: layer.parentElement, scrub: true, start: "top bottom", end: "bottom top" },
});
});
```
## 6. Linked timelines / containerAnimation (triggers inside horizontal scroll)
To trigger animations off elements that move horizontally inside a pinned horizontal scroller, pass the horizontal tween as `containerAnimation`:
```js
const scrollTween = gsap.to(track, { x: -2000, ease: "none",
scrollTrigger: { trigger: ".h-wrap", pin: true, scrub: 1, end: "+=2000" } });
gsap.from(".panel-2 .title", {
autoAlpha: 0, y: 50,
scrollTrigger: { trigger: ".panel-2 .title", containerAnimation: scrollTween, start: "left center" },
});
```
Note `start: "left center"` uses horizontal keywords because the trigger moves horizontally.
## 7. Refresh on dynamic content
Images and lazy content change layout after triggers are computed. Refresh when ready:
```js
window.addEventListener("load", () => ScrollTrigger.refresh());
imagesLoaded(document.body, () => ScrollTrigger.refresh()); // if using imagesLoaded lib
```
For accordions/tabs that change height, call `ScrollTrigger.refresh()` after the layout settles.
## 8. SPA / React cleanup (critical)
Orphaned ScrollTriggers cause ghost pins, leaks, and stacking bugs on route changes. Always scope and revert.
React with `@gsap/react`:
```jsx
import { useGSAP } from "@gsap/react";
function Section() {
const root = useRef(null);
useGSAP(() => {
gsap.from(".item", {
autoAlpha: 0, y: 40, stagger: 0.1,
scrollTrigger: { trigger: ".item", start: "top 80%" },
});
}, { scope: root }); // auto-reverts on unmount
return <div ref={root}>...</div>;
}
```
Plain SPA / manual:
```js
const ctx = gsap.context(() => { /* all gsap code */ }, rootEl);
// on teardown / route leave:
ctx.revert();
```
Nuclear option (e.g. global router hook): `ScrollTrigger.getAll().forEach((t) => t.kill());`
## Debugging checklist
- Triggers fire at wrong scroll position → likely created before images loaded; call `ScrollTrigger.refresh()`.
- Pinned element jumps to top-left → it has a transformed/`will-change`/`overflow:hidden` ancestor; pin higher up.
- Animation plays once then never resets → set appropriate `toggleActions` or use `scrub`.
- Horizontal scroll not responsive → use function-based `x`/`end` + `invalidateOnRefresh: true`.
- Markers misaligned → leftover triggers from previous render; ensure cleanup runs.
@@ -0,0 +1,255 @@
# ScrollTrigger + Lenis / Locomotive smooth-scroll sync
Wire GSAP ScrollTrigger together with Lenis (or Locomotive) smooth scrolling so there is no jitter, no marker drift, no broken pins, and no flipped/laggy scroll direction. Every one of those bugs has the same root cause: two animation loops running independently. Lenis transforms the scroll position on its own `requestAnimationFrame` loop while ScrollTrigger reads scroll on GSAP's ticker — when they tick separately, ScrollTrigger samples a stale position and everything desyncs. The fix is to run one loop: let GSAP's ticker drive Lenis, and tell ScrollTrigger to update on every Lenis scroll.
## The one canonical wiring (Lenis)
This is the entire correct setup. Do not add a separate `requestAnimationFrame` loop for Lenis — that is the most common mistake and the source of nearly all desync.
```js
import Lenis from 'lenis';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({
duration: 1.2,
smoothWheel: true,
// do NOT enable Lenis's own rAF; GSAP's ticker drives it (below)
});
// 1. Update ScrollTrigger every time Lenis scrolls
lenis.on('scroll', ScrollTrigger.update);
// 2. Drive Lenis from GSAP's ticker (single shared loop).
// gsap.ticker time is in SECONDS; Lenis expects MILLISECONDS.
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
// 3. Disable GSAP's lag smoothing so ScrollTrigger never defers/
// catches up against Lenis (prevents jitter on heavy frames)
gsap.ticker.lagSmoothing(0);
```
Three lines do all the work:
1. `lenis.on('scroll', ScrollTrigger.update)` — ScrollTrigger recomputes positions the instant Lenis moves the page, so triggers/markers/pins track the smoothed scroll exactly.
2. `gsap.ticker.add(time => lenis.raf(time * 1000))` — Lenis advances inside GSAP's loop, guaranteeing both run on the same frame and in the same order. Critical unit conversion: `gsap.ticker` passes seconds, `lenis.raf()` wants milliseconds, so multiply by 1000.
3. `gsap.ticker.lagSmoothing(0)` — disables GSAP's catch-up behavior that, combined with Lenis, causes visible jitter when a frame is slow.
After this, never call `requestAnimationFrame(raf)` for Lenis yourself. If any tutorial code has a standalone `function raf(time){ lenis.raf(time); requestAnimationFrame(raf) }` loop, delete it — it competes with the ticker and reintroduces jitter and direction lag.
## Locomotive Scroll variant
Locomotive needs `scrollerProxy` because it scrolls an inner element, not `window`. The principle is identical (one loop, update ScrollTrigger on scroll), but the proxy maps coordinates:
```js
const locoScroll = new LocomotiveScroll({
el: document.querySelector('[data-scroll-container]'),
smooth: true,
});
locoScroll.on('scroll', ScrollTrigger.update);
ScrollTrigger.scrollerProxy('[data-scroll-container]', {
scrollTop(value) {
return arguments.length
? locoScroll.scrollTo(value, { duration: 0, disableLerp: true })
: locoScroll.scroll.instance.scroll.y;
},
getBoundingClientRect() {
return { top: 0, left: 0, width: innerWidth, height: innerHeight };
},
pinType: document.querySelector('[data-scroll-container]').style.transform
? 'transform' : 'fixed',
});
ScrollTrigger.addEventListener('refresh', () => locoScroll.update());
ScrollTrigger.refresh();
```
Every ScrollTrigger then needs `scroller: '[data-scroll-container]'`. Lenis is simpler because it scrolls the real document, so no `scrollerProxy` is needed.
## Pins, refresh, and resize
- Pinned ScrollTriggers work without `scrollerProxy` under Lenis because Lenis scrolls the document body. If pins still jump, ensure no rogue rAF loop exists and that `ScrollTrigger.update` is wired.
- On dynamic content/resize, call `ScrollTrigger.refresh()` after layout settles. With Lenis, also call `lenis.resize()` if container size changes.
- For elements that should *not* be smoothed (e.g. a modal that scrolls natively), mark with `data-lenis-prevent` so Lenis ignores them.
## Anchor links with smooth scroll
Native `<a href="#section">` jumps bypass Lenis (instant jump, then Lenis fights it). Route anchors through `lenis.scrollTo`:
```js
document.querySelectorAll('a[href^="#"]').forEach((a) => {
a.addEventListener('click', (e) => {
const id = a.getAttribute('href');
if (id.length > 1) {
e.preventDefault();
lenis.scrollTo(id, { offset: 0 }); // accepts selector, element, or number
}
});
});
```
If a sticky header overlaps the target, pass a negative offset or an offset callback:
```js
lenis.scrollTo(targetEl, { offset: -80 }); // 80px sticky header
```
## Reduced-motion gate
Respect `prefers-reduced-motion`: skip smooth scrolling entirely (do not instantiate Lenis, or stop it) and let ScrollTrigger run on native scroll.
```js
const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let lenis;
if (!prefersReduced) {
lenis = new Lenis({ duration: 1.2 });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
}
// ScrollTrigger animations are defined normally and work with or without Lenis
```
## Cleanup (SPA / React / route changes)
Destroy Lenis and remove the ticker callback on unmount, or stacked loops accumulate:
```js
function setup() {
const update = (time) => lenis.raf(time * 1000);
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add(update);
gsap.ticker.lagSmoothing(0);
return () => {
gsap.ticker.remove(update);
lenis.destroy();
ScrollTrigger.getAll().forEach((t) => t.kill());
};
}
```
## Debugging checklist (work top to bottom)
1. **Is there a rogue rAF loop?** Search the codebase for `requestAnimationFrame` near `lenis.raf`. If Lenis is also driven by `gsap.ticker`, delete the standalone loop. Two loops = jitter, lag, flipped feel.
2. **Unit conversion present?** Confirm `lenis.raf(time * 1000)` inside the ticker. Missing `* 1000` makes scroll feel stuck or wildly slow.
3. **Is `ScrollTrigger.update` subscribed?** `lenis.on('scroll', ScrollTrigger.update)` must exist. Without it, markers and triggers drift behind the smoothed position.
4. **Is `lagSmoothing(0)` set?** Without it, heavy frames cause GSAP to "catch up", visible as jitter.
5. **Markers on for diagnosis.** Add `markers: true` to a trigger. If markers sit at the *native* scroll position but content moves with smoothed scroll, the update subscription is missing or a proxy is mis-set.
6. **Pins jumping?** Confirm no proxy is set for Lenis (it scrolls the document). For Locomotive, confirm `pinType` and `scroller` are correct.
7. **After dynamic content / fonts / images load**, call `ScrollTrigger.refresh()`. Stale measurements cause triggers to fire at wrong offsets.
8. **Nested scroll areas** (modals, code blocks) misbehave? Add `data-lenis-prevent` to the scrollable child.
9. **Cleanup on route change?** In SPAs, verify `gsap.ticker.remove(update)`, `lenis.destroy()`, and killing ScrollTriggers — otherwise each navigation stacks another loop.
## Symptom → cause table
| Symptom | Most likely cause |
|---|---|
| General jitter/stutter | Standalone rAF loop + ticker both driving Lenis |
| Scroll stuck / barely moves | Missing `* 1000` unit conversion |
| Markers offset from triggers | `ScrollTrigger.update` not bound to `lenis.on('scroll')` |
| Jitter only on heavy frames | `lagSmoothing(0)` not set |
| Pins jump / break | Proxy wrongly set under Lenis, or wrong `pinType` (Loco) |
| Animations a frame behind | Two loops out of order; consolidate to one ticker |
| Anchor links jump then snap back | Native `#hash` jump not routed via `lenis.scrollTo` |
| Triggers fire at wrong scroll position | Missing `ScrollTrigger.refresh()` after content/layout change |
| Worse after each route change | No cleanup; loops/triggers accumulating |
| Inner scroll container won't scroll | Lenis intercepting; add `data-lenis-prevent` |
## Complete React integration (useGSAP)
```jsx
'use client';
import { useRef } from 'react';
import Lenis from 'lenis';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
export default function ScrollSection() {
const root = useRef(null);
useGSAP(() => {
const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let lenis;
let update;
if (!prefersReduced) {
lenis = new Lenis({ duration: 1.2, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
update = (time) => lenis.raf(time * 1000);
gsap.ticker.add(update);
gsap.ticker.lagSmoothing(0);
}
gsap.to('.panel', {
xPercent: -100,
ease: 'none',
scrollTrigger: {
trigger: '.panel',
start: 'top top',
end: '+=1500',
scrub: true,
pin: true,
// markers: true, // enable while debugging
},
});
return () => {
if (lenis) {
gsap.ticker.remove(update);
lenis.destroy();
}
};
}, { scope: root });
return (
<div ref={root}>
<section className="panel">…</section>
</div>
);
}
```
`useGSAP` auto-reverts GSAP tweens/ScrollTriggers created in its scope on unmount; the returned function additionally tears down Lenis and the ticker callback so nothing leaks across navigations.
## Plain useEffect equivalent
```jsx
useEffect(() => {
const lenis = new Lenis({ duration: 1.2 });
lenis.on('scroll', ScrollTrigger.update);
const update = (t) => lenis.raf(t * 1000);
gsap.ticker.add(update);
gsap.ticker.lagSmoothing(0);
const ctx = gsap.context(() => {
// ...ScrollTrigger animations here...
});
return () => {
gsap.ticker.remove(update);
lenis.destroy();
ctx.revert();
};
}, []);
```
## Excluding regions from smooth scroll
```html
<div class="code-block" data-lenis-prevent>…natively scrollable…</div>
```
`data-lenis-prevent` on a scrollable element makes Lenis ignore wheel/touch there, so inner scrolling works normally while the page stays smooth.
---
Sync ScrollTrigger to smooth scroll and the timeline tracks the page perfectly. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=gsap-web)** — the AI motion agent for editable, on-brand motion graphics.