GSAP ScrollTrigger in React: Pinning, Scrubbing and Timeline Sync
Master GSAP ScrollTrigger in React — pin sections, scrub timelines to scroll position, and sync complex animations without memory leaks or StrictMode bugs.
Why ScrollTrigger and React Fight Each Other (and How to Fix It)
GSAP ScrollTrigger is, genuinely, the best scroll animation library available. Nothing else gives you this level of control over pinning, scrubbing, and timeline synchronization. But dropping it into a React app without thinking will burn you — StrictMode double-invokes effects, the component unmounts before your ScrollTrigger cleanup runs, and you end up with ghost animations that fire on routes you've already left.
The core tension: React owns the DOM lifecycle, and GSAP wants to own it too. ScrollTrigger (introduced properly in GSAP 3.3.0) stores scroll positions globally. React tears down and remounts components in development. Those two facts together cause the majority of bugs you'll hit.
Honestly, the fix isn't complicated — it's just not obvious the first time. You need a consistent cleanup pattern, a useLayoutEffect instead of useEffect for synchronous DOM reads, and a scoped gsap.context() call so you can kill everything cleanly on unmount. We'll cover all three.
One more thing — if you're on React 18 StrictMode, effects run twice in dev. Your ScrollTrigger instances will double-register unless you're calling ctx.revert() in your cleanup return. We'll come back to this.
Project Setup: Installing GSAP and Registering the Plugin
Start with the install. GSAP 3.x is the version you want — ScrollTrigger ships as a separate plugin inside the same package:
npm install gsapScrollTrigger needs to be registered once, globally. Do it at the top of your entry file or in a dedicated animation utility module — not inside individual components. Registering it multiple times isn't fatal but it's messy:
// src/lib/gsap.js
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export { gsap, ScrollTrigger };Now import from src/lib/gsap.js everywhere else. That way registration is guaranteed before any component tries to use it. Worth noting: if you're using Next.js App Router with server components, you'll need to mark any component that uses GSAP with 'use client' — GSAP touches window and won't survive a server render.
Quick aside: GSAP's Club plugins (SplitText, MorphSVG etc.) need a slightly different import path from the downloaded zip. The free ScrollTrigger plugin lives in the standard gsap package so you don't need a Club license for anything in this article.
The useGSAP Hook Pattern: Cleanup That Actually Works
GSAP ships an official useGSAP hook as of version 3.12. It wraps useLayoutEffect, creates a scoped context, and calls ctx.revert() on unmount automatically. Use it. Don't roll your own unless you have a specific reason.
npm install @gsap/reactimport { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from '../lib/gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function PinSection() {
const containerRef = useRef(null);
useGSAP(() => {
gsap.to('.pin-content', {
x: -1200,
ease: 'none',
scrollTrigger: {
trigger: containerRef.current,
start: 'top top',
end: '+=1200',
scrub: 1,
pin: true,
},
});
}, { scope: containerRef });
return (
<div ref={containerRef} className="pin-wrapper">
<div className="pin-content" style={{ display: 'flex', width: '2400px' }}>
<section className="panel">Panel One</section>
<section className="panel">Panel Two</section>
</div>
</div>
);
}The scope: containerRef option tells GSAP to scope all selector queries inside that element. That's what makes '.pin-content' resolve to the right DOM node even if you mount this component multiple times. Without scope you're selecting globally and you'll get weird behavior when the same class appears elsewhere.
In practice, useLayoutEffect matters here because ScrollTrigger needs to measure element dimensions synchronously before the browser paints. If you use useEffect instead, you can get a one-frame flash where the initial position is wrong — subtle in dev, annoying in prod.
Pinning Sections: Sticky Scroll Done Right
Pinning is ScrollTrigger's headline feature. You set pin: true and GSAP freezes the element in place while the page scrolls underneath it — giving you that horizontal scroll, feature callout, or hero lock effect that's everywhere in 2026.
The end value controls how long the pin lasts. end: '+=1200' means "1200px of scroll distance after the start point". You can also use end: 'bottom top' to release the pin when the element's bottom edge hits the viewport top. Which one you use depends on whether you're thinking in scroll distance or element position.
scrollTrigger: {
trigger: sectionRef.current,
start: 'top top', // when section top hits viewport top
end: '+=2000', // pin for 2000px of scroll
pin: true,
pinSpacing: true, // adds padding below so content doesn't jump
anticipatePin: 1, // prevents snap-back on fast scroll
}Set anticipatePin: 1 any time your pinned element is more than about 300px down the page. Without it, fast scrolling can cause the pin to trigger a frame late and you'll see a visual pop. That specific option was added in GSAP 3.6.0 and it's been a quiet lifesaver.
One more thing — pinSpacing is true by default, which adds a spacer element equal to the pin duration below your section. This keeps the rest of the page from jumping up when the pin releases. Turn it off only if you're building overlapping scroll sections intentionally.
Scrubbing Timelines to Scroll Position
Scrub is where ScrollTrigger gets genuinely powerful. Instead of playing an animation on a trigger, you tie the playhead of a GSAP timeline directly to the scroll position. Scroll down, animation moves forward. Scroll up, it reverses. The scrub value controls the lag — scrub: true for instant sync, scrub: 1 for a 1-second smoothing delay, scrub: 0.5 for snappier response.
Here's a full timeline sync example — a text reveal that tracks scroll with 60px of element movement:
useGSAP(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
start: 'top 80%',
end: 'bottom 20%',
scrub: 0.5,
},
});
tl.from('.hero-title', { opacity: 0, y: 60, duration: 1 })
.from('.hero-subtitle', { opacity: 0, y: 40, duration: 0.8 }, '-=0.4')
.from('.hero-cta', { opacity: 0, scale: 0.9, duration: 0.6 }, '-=0.3');
}, { scope: containerRef });That -=0.4 offset syntax staggers items in the timeline while still scrubbing as one unit. The timeline's total duration is what maps to the scroll distance between start and end. Honestly, this is the cleanest API for this kind of staggered entrance I've seen in any scroll library.
Look, there's a common mistake here: putting ease on scrubbed animations. When you scrub, GSAP overrides the ease because it's mapping time directly to scroll progress. The ease still affects the curve shape in some contexts, but don't rely on it — use ease: 'none' for linear scrub behavior and control the feel through scrub lag instead.
For inspiration on what these scroll animations look like when paired with visual styles like glassmorphism cards or aurora backgrounds, check the Empire UI component library — the demos there combine ScrollTrigger with CSS-driven design tokens in ways that are worth studying.
Handling Cleanup in StrictMode and Dynamic Routes
React 18 StrictMode double-mounts every component in development. This means your useGSAP callback fires twice — and if your cleanup isn't airtight, you'll have two ScrollTrigger instances watching the same element. In production this doesn't happen, so you'll only see the bug in dev. Classic.
useGSAP from @gsap/react handles this correctly. The ctx.revert() call in cleanup kills all animations and ScrollTriggers created inside that context. What it doesn't handle: any GSAP calls you make outside the useGSAP callback. Keep all your animation code inside it.
// Safe pattern with dependency-driven re-init
const { items } = props;
useGSAP(() => {
items.forEach((item, i) => {
gsap.from(`[data-item="${i}"]`, {
opacity: 0,
y: 30,
delay: i * 0.1,
scrollTrigger: {
trigger: `[data-item="${i}"]`,
start: 'top 85%',
toggleActions: 'play none none reverse',
},
});
});
}, { scope: containerRef, dependencies: [items] });The dependencies array works like useEffect deps — when items changes, the context reverts and re-runs. That gives you reactive animation without manual cleanup logic.
For dynamic routes in Next.js App Router, wrap your animated page in a component that has a stable key prop tied to the route. That forces a full unmount-remount on navigation, which kills all ScrollTrigger instances cleanly. Without that, navigating between pages in the same layout can leave zombie triggers that fire on the wrong scroll position. It's a subtle bug and it'll waste your afternoon. If you're building scroll-heavy pages alongside more static content, the css-scroll-animations article covers the pure-CSS fallback approaches worth knowing.
Performance: Avoiding Reflows and GPU Overload
Scroll animations tank performance when they animate properties that cause layout reflow — width, height, top, left, margin. Stick to transform and opacity. Always. The browser can offload those to the compositor thread without touching the layout engine, which is the difference between 60fps and a janky mess.
Add will-change: transform to elements you're going to animate before the animation starts. Don't apply it globally — that's a memory tax. Add it via CSS to specific targets right before they become active, and remove it after. For ScrollTrigger, you can do this in onEnter / onLeave callbacks:
scrollTrigger: {
trigger: panelRef.current,
start: 'top 90%',
onEnter: () => panelRef.current.style.willChange = 'transform',
onLeaveBack: () => panelRef.current.style.willChange = 'auto',
toggleActions: 'play none none reverse',
}Worth noting: GSAP sets transform3d by default which forces GPU compositing even without will-change. That's generally fine. Where it gets expensive is stacking many pinned sections — each pin creates a new stacking context and the browser has to composite all of them independently. Keep pinned sections below 5 on a single page if you can.
If your scroll animations need to work alongside heavier visual effects like gradient backgrounds or blurred layers from the glassmorphism generator, test on a mid-range Android device, not just your M3 MacBook. The performance gap is brutal and you'll catch problems early. Blend modes, backdrop-filter, and GSAP transforms on the same element simultaneously — that's where frames start dropping.
FAQ
No. ScrollTrigger is free and ships inside the standard gsap npm package. Club licenses are only needed for plugins like SplitText, MorphSVG, or Draggable.
React 18 StrictMode double-invokes effects. Use the official useGSAP hook from @gsap/react — it creates a scoped context and calls ctx.revert() on cleanup, which kills duplicate instances automatically.
scrub: true links the animation directly to scroll with zero lag. scrub: 1 adds a 1-second smoothing delay so the animation catches up to scroll position gradually — feels more physical.
No — GSAP accesses window and must run client-side. Add 'use client' to any component that uses GSAP or ScrollTrigger. Server components will throw on import.