GSAP with React in 2026: ScrollTrigger, Timeline and the New Way
GSAP 3.12 with React in 2026 means useGSAP, Context cleanup, and ScrollTrigger that actually works with concurrent mode. Here's the real setup.
Why GSAP Still Wins in 2026
Framer Motion is great. CSS animations are fine. But when you need precise, sequenced, scroll-driven motion at 60fps with full control over every interpolated value — GSAP is still the answer in 2026, and it's not particularly close.
The library hit version 3.12 last year and the React integration story finally caught up with the rest of it. The old useEffect + gsap.context() pattern worked, but it was boilerplate-heavy and easy to mess up. You'd forget to revert, leak animations into sibling components, or fight StrictMode double-invocations in dev. The new useGSAP hook solves all of that.
Honestly, the bigger story isn't GSAP itself — it's that the React ecosystem finally stopped fighting against external DOM mutation libraries. Concurrent mode, useRef-first patterns, and better cleanup APIs mean GSAP and React coexist without the friction you'd expect.
Worth noting: GSAP's ScrollTrigger plugin is free for personal projects. The Club GreenSock plugins (SplitText, MorphSVG, etc.) require a membership, but the core toolkit — including ScrollTrigger — is completely open.
Setting Up GSAP and useGSAP the Right Way
First, install the package. That's npm install gsap. No separate @gsap/react package needed since 3.11 — useGSAP ships with the main bundle.
The key mental shift: stop putting GSAP code in useEffect. Use useGSAP instead. It wraps a context internally, handles cleanup on unmount automatically, and re-runs correctly when deps change. Here's the minimal setup:
import { useRef } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(useGSAP);
export function FadeInCard({ children }) {
const containerRef = useRef(null);
useGSAP(() => {
gsap.from('.card', {
opacity: 0,
y: 40,
duration: 0.6,
ease: 'power2.out',
});
}, { scope: containerRef });
return (
<div ref={containerRef}>
<div className="card">{children}</div>
</div>
);
}The scope option is what makes this clean. GSAP scopes all selector queries to containerRef, so .card only matches inside your component — not every .card on the page. This is the fix for one of the oldest GSAP + React footguns.
Quick aside: always call gsap.registerPlugin() outside the component, at module level. Calling it inside the component body re-registers on every render. Minor performance hit but it adds up.
Building Timelines That Don't Break on Re-render
Timelines are where GSAP gets interesting. Sequencing five elements to animate in after each other, with overlap and stagger, is something CSS transitions genuinely can't do cleanly.
The pattern here is to store the timeline in a ref, then control it externally — pause, play, reverse — without recreating it. useGSAP returns a context object with a contextSafe wrapper for exactly this use case:
import { useRef } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
export function StaggeredList({ items }) {
const container = useRef(null);
const tl = useRef(null);
const { contextSafe } = useGSAP(() => {
tl.current = gsap.timeline({ paused: true })
.from('.item', {
opacity: 0,
x: -24,
duration: 0.4,
stagger: 0.08,
ease: 'power1.out',
});
}, { scope: container });
const handlePlay = contextSafe(() => tl.current.play());
const handleReverse = contextSafe(() => tl.current.reverse());
return (
<div ref={container}>
{items.map((item) => (
<div className="item" key={item.id}>{item.label}</div>
))}
<button onClick={handlePlay}>Play</button>
<button onClick={handleReverse}>Reverse</button>
</div>
);
}The contextSafe wrapper ensures that any GSAP calls inside your event handlers are tracked by the same context — so they get cleaned up properly when the component unmounts. Skip it and you'll get stale animation state that outlives the component.
In practice, stagger values around 0.06–0.1 seconds feel snappy without being chaotic. Go above 0.15 on lists longer than 5 items and users start noticing the wait.
ScrollTrigger in React Without the Headaches
ScrollTrigger is the plugin people come to GSAP for. Scroll-linked animations, pinning, scrub effects — it handles all of it. The React setup adds one wrinkle: you need to register the plugin before using it, and you need to be careful about when ScrollTrigger initializes relative to layout.
Register at the top of your file: gsap.registerPlugin(ScrollTrigger). Then use it inside useGSAP with a scope ref so the element is definitely mounted before ScrollTrigger tries to measure it. Here's a pinned section with a scrubbed opacity animation — a pattern used constantly in landing pages:
import { useRef } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
export function ParallaxSection() {
const sectionRef = useRef(null);
useGSAP(() => {
gsap.fromTo(
'.parallax-text',
{ opacity: 0, y: 60 },
{
opacity: 1,
y: 0,
ease: 'none',
scrollTrigger: {
trigger: '.parallax-text',
start: 'top 80%',
end: 'top 40%',
scrub: 1,
},
}
);
}, { scope: sectionRef });
return (
<section ref={sectionRef} style={{ minHeight: '100vh', padding: '120px 40px' }}>
<h2 className="parallax-text" style={{ fontSize: '3rem' }}>
This fades in as you scroll
</h2>
</section>
);
}The scrub: 1 value means the animation smoothly follows the scroll position with a 1-second lag. Set it to true for instant scrubbing, or a number for the smoothing duration. For UI effects like the ones you'd build inspired by glassmorphism components, scrub values between 0.5 and 1.5 tend to feel polished.
One more thing — if you're using React Router or Next.js App Router and navigating between pages, ScrollTrigger instances don't auto-kill. Call ScrollTrigger.getAll().forEach(t => t.kill()) in a cleanup effect or route change handler, or you'll accumulate dead triggers.
Animating on Route Change with Next.js App Router
Page transitions are a legitimate use case, and in 2026 with Next.js 15's App Router they're still a bit awkward. There's no built-in onRouteChange hook — you have to wire it yourself with usePathname from next/navigation.
The pattern that works: animate out in a layout component, then animate in on the new page. Keep it under 400ms total or users start feeling the delay as sluggishness rather than polish. A 200ms fade with a 16px Y translate is the sweet spot — enough to register, not enough to annoy.
Look, the honest answer here is that for pure page transitions in Next.js you might reach for Framer Motion's AnimatePresence because the exit animation story is cleaner. GSAP shines on scroll-driven and timeline-heavy work. Use the right tool for each job — they don't have to be mutually exclusive in the same project.
That said, if you're building something visually ambitious — the kind of experience you'd see in the templates section — GSAP timelines on entry are worth the extra wiring.
Performance: What Actually Matters
GSAP animates transform and opacity by default. Both are GPU-composited. You won't cause layout reflows with those. The problem comes when people animate width, height, top, left, or margin — those trigger layout recalculation on every frame and will tank performance regardless of what animation library you're using.
For anything that moves in 2D, use x and y in GSAP (which maps to translateX/translateY). Not left and top. A translate of 40px is { y: 40 }, not { top: '40px' }. This is the single most common performance mistake and it's easy to miss because both "work" visually.
The will-change: transform CSS property is worth adding to elements you know will animate — it promotes them to their own compositor layer ahead of time. Don't spray it everywhere though; each composited layer consumes GPU memory. Target it at the specific elements that animate on scroll or on interaction.
Quick aside: GSAP's gsap.ticker runs at the display's native refresh rate — 120Hz on modern displays, 60Hz as a fallback. You don't need requestAnimationFrame loops of your own. Let GSAP handle the timing and focus on what the animation should do.
If you're building something heavily animated and want inspiration for the visual language, browse the components on Empire UI — a lot of them use layered transforms and opacity that map directly to GSAP timeline sequences.
Debugging GSAP in React StrictMode
React 18's StrictMode double-invokes effects in development. This means your useGSAP callback runs twice, which can cause animations to fire twice, timelines to get created twice, or ScrollTrigger instances to stack. It's disorienting if you don't know what's happening.
useGSAP handles this better than raw useEffect + gsap.context() because it reverts the first invocation automatically before the second one runs. That said, if you're storing timeline refs and seeing weird double-start behavior in dev, verify you're using useGSAP and not useEffect for the setup.
The GSAP DevTools browser extension (free, available for Chrome) lets you see all active tweens, timelines, and ScrollTrigger instances in real time. It's invaluable. You can pause, scrub, and inspect duration and easing on every active animation. Use it from day one on any non-trivial animation project.
What's the fastest way to check if a ScrollTrigger is firing? Add markers: true to the ScrollTrigger config temporarily. It renders the start/end trigger lines directly in the page at their exact calculated positions. Remove it before shipping.
FAQ
No. Since GSAP 3.11, the useGSAP hook ships in the main gsap package. Just npm install gsap and import useGSAP from @gsap/react — it resolves from the same package.
GSAP touches the DOM, so it can only run in Client Components. Mark any component that uses GSAP with 'use client' at the top. There's no server-side rendering path for GSAP animations.
Check your start value — 'top top' means the animation triggers when the element's top hits the viewport's top, which might already be true on load. Try 'top 80%' to trigger when the element enters from the bottom.
The core GSAP library and ScrollTrigger are free for commercial use under the standard license. The premium Club GreenSock plugins (SplitText, DrawSVG, etc.) require a paid membership.