GSAP in React: How to Use the useGSAP Hook (2026)
Learn how to use GSAP in React with the useGSAP hook. Covers setup, refs, scoping, contextSafe callbacks, ScrollTrigger cleanup, and Next.js SSR patterns.

If you've tried using GSAP in React with useEffect, you've probably run into the cleanup problem. Animations that keep running after a component unmounts. ScrollTriggers that don't get destroyed. Memory leaks in development mode that are hard to trace.
The official solution is the useGSAP hook from @gsap/react. It handles cleanup automatically, scopes your selectors, and gives you a clean way to handle event-driven animations.
Here's everything you need to know.
What You'll Need
- React 18+ or Next.js 14+
- GSAP installed in your project
- Basic familiarity with
useRefanduseEffect
Installation
npm install gsap @gsap/react
The @gsap/react package is a thin wrapper maintained by the GreenSock team. It adds the useGSAP hook and nothing else. GSAP itself is still the main package.
The Basic Pattern
Here's how most GSAP animations work in React:
import { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
// Register the plugin once, outside the component
gsap.registerPlugin(useGSAP)
export default function HeroSection() {
const containerRef = useRef(null)
useGSAP(
() => {
gsap.from('.hero-title', {
y: 40,
opacity: 0,
duration: 0.8,
ease: 'power3.out',
})
gsap.from('.hero-subtitle', {
y: 20,
opacity: 0,
duration: 0.6,
ease: 'power3.out',
delay: 0.2,
})
},
{ scope: containerRef }
)
return (
<div ref={containerRef}>
<h1 className="hero-title">Build Better Animations</h1>
<p className="hero-subtitle">With GSAP and React.</p>
</div>
)
}
Three things to notice:
gsap.registerPlugin(useGSAP)runs once outside the component, not inside it.- The
scope: containerRefoption means.hero-titleand.hero-subtitleonly match elements inside that container. - Cleanup is automatic. When the component unmounts, all animations in this context are reverted.
Why Scope Matters
Without a scope, GSAP's selector strings search the entire document. In a React app with multiple components, .hero-title could match any element with that class. Scoping pins the search to the component's subtree.
// Without scope — risky
useGSAP(() => {
gsap.to('.card', { scale: 1.05 }) // matches ALL .card elements on the page
})
// With scope — safe
useGSAP(
() => {
gsap.to('.card', { scale: 1.05 }) // only matches .card inside containerRef
},
{ scope: containerRef }
)
For most animations, passing scope is the safe default. The only time you skip it is when you're intentionally targeting elements outside the component.
Using Refs Directly
For single elements, targeting by ref is more explicit and avoids string selectors entirely:
export default function AnimatedCard() {
const cardRef = useRef(null)
const titleRef = useRef(null)
useGSAP(() => {
const tl = gsap.timeline()
tl.from(cardRef.current, {
y: 30,
opacity: 0,
duration: 0.7,
ease: 'power3.out',
})
tl.from(
titleRef.current,
{
y: 15,
opacity: 0,
duration: 0.5,
},
'-=0.3'
)
})
return (
<div ref={cardRef} className="card">
<h2 ref={titleRef}>Card Title</h2>
</div>
)
}
When using direct refs like this, you don't need scope because you're not using selector strings at all.
Visual: PLACEHOLDER - Code side-by-side showing selector-based vs ref-based GSAP targeting in React
Animating on State Changes
By default, useGSAP runs once after the initial render (like useEffect with an empty dependency array). If you need to re-run animations when state changes, pass a dependency array:
const [isOpen, setIsOpen] = useState(false)
const panelRef = useRef(null)
useGSAP(
() => {
if (isOpen) {
gsap.to(panelRef.current, { height: 'auto', opacity: 1, duration: 0.4 })
} else {
gsap.to(panelRef.current, { height: 0, opacity: 0, duration: 0.3 })
}
},
{
scope: panelRef,
dependencies: [isOpen],
revertOnUpdate: true, // revert previous animation before re-running
}
)
The revertOnUpdate: true option tells GSAP to clean up the previous animation run before running again. Without it, you can end up with conflicting tweens when isOpen flips quickly.
Event-Driven Animations with contextSafe
Here's a pattern that trips up a lot of developers. If you create a GSAP animation inside an event handler, it runs outside the useGSAP context. That means it won't be cleaned up on unmount.
const containerRef = useRef(null)
const buttonRef = useRef(null)
useGSAP(
(context, contextSafe) => {
// This runs at setup time — safely inside context
gsap.from(containerRef.current, { opacity: 0, duration: 0.5 })
// Wrap event handler animations in contextSafe
const handleClick = contextSafe(() => {
gsap.to(buttonRef.current, {
scale: 0.95,
duration: 0.1,
yoyo: true,
repeat: 1,
})
})
buttonRef.current.addEventListener('click', handleClick)
// Remove listener in the cleanup return
return () => {
buttonRef.current.removeEventListener('click', handleClick)
}
},
{ scope: containerRef }
)
contextSafe wraps the function so it runs inside the GSAP context. If the component unmounts before the animation completes, GSAP handles the cleanup correctly instead of trying to update a detached node.
This matters more than it seems in React apps with frequent unmounts (route changes, conditional rendering, Suspense).
Hover Animations
Hover effects in React often need both a "play" and a "reverse" state. Here's a clean pattern:
export default function AnimatedButton() {
const buttonRef = useRef(null)
const tl = useRef(null)
useGSAP(
() => {
tl.current = gsap.timeline({ paused: true })
tl.current.to(buttonRef.current, { scale: 1.04, duration: 0.3, ease: 'power2.out' })
},
{ scope: buttonRef }
)
return (
<button
ref={buttonRef}
onMouseEnter={() => tl.current.play()}
onMouseLeave={() => tl.current.reverse()}
>
Hover Me
</button>
)
}
Store the timeline in a ref (useRef) so it's accessible in the event handlers without being a dependency. Creating it with paused: true prevents autoplay.
With ScrollTrigger
ScrollTrigger works the same way inside useGSAP. GSAP handles the cleanup automatically.
import { useRef } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(ScrollTrigger, useGSAP)
export default function AnimatedSection() {
const sectionRef = useRef(null)
useGSAP(
() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: sectionRef.current,
start: 'top 80%',
once: true,
},
})
tl.from('.section-heading', { y: 30, opacity: 0, duration: 0.8 })
tl.from('.section-body', { y: 20, opacity: 0, duration: 0.6 }, '-=0.3')
},
{ scope: sectionRef }
)
return (
<section ref={sectionRef}>
<h2 className="section-heading">Section Title</h2>
<p className="section-body">Section content here.</p>
</section>
)
}
When this component unmounts, useGSAP automatically calls ScrollTrigger.kill() for any triggers created inside the hook. No manual cleanup needed.
Next.js and Server-Side Rendering
GSAP runs in the browser. In Next.js with the App Router, components can render on the server. The rule is simple: all GSAP code must run inside useGSAP (or useEffect). Never call gsap.* or ScrollTrigger.* at the top level of a module.
// Wrong — runs during SSR
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger) // This runs on the server. It will fail.
// Right — register inside the component or a client-only effect
;('use client')
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(ScrollTrigger, useGSAP) // This is fine at module level in 'use client' files
In Next.js App Router, add "use client" to any component file that uses GSAP. Server Components cannot use browser APIs.
Still Using useEffect?
If you can't use @gsap/react for some reason, gsap.context() inside useEffect is the fallback:
import { useEffect, useRef } from 'react'
import gsap from 'gsap'
export default function FallbackComponent() {
const containerRef = useRef(null)
useEffect(() => {
const ctx = gsap.context(() => {
gsap.from('.box', { x: -50, opacity: 0, duration: 0.6 })
}, containerRef)
return () => ctx.revert() // ALWAYS revert in cleanup
}, [])
return (
<div ref={containerRef}>
<div className="box">Animated</div>
</div>
)
}
The key difference: with useEffect you have to manually call ctx.revert() in the cleanup. With useGSAP, that happens automatically. I'd always recommend useGSAP when it's available.
Common Mistakes
Not registering the plugin. Forgetting gsap.registerPlugin(useGSAP) means the hook won't behave correctly.
Skipping scope. Without scope, class selectors match the entire document. In an app with multiple instances of the same component, this is a guaranteed bug.
Creating event-handler animations without contextSafe. These won't be cleaned up on unmount. Use contextSafe or store tweens in refs.
Running GSAP code outside the hook. GSAP code at module level or in render functions runs during SSR. Keep all GSAP code inside useGSAP or useEffect.
Key Takeaways
- Use
useGSAPfrom@gsap/reactinstead ofuseEffectfor GSAP in React. - Always pass
scopewhen using selector strings so animations don't leak outside the component. - Wrap event-handler animations in
contextSafeto ensure proper cleanup. - All GSAP code must run in
useGSAPoruseEffect. Never at the top level in Next.js. - ScrollTrigger cleanup is automatic inside
useGSAP.
Take It Further
If you're building scroll animations in React, combine useGSAP with the patterns in GSAP ScrollTrigger Examples. The two work seamlessly together.
For complex sequences, read the GSAP Timeline Tutorial. Timelines inside useGSAP work exactly the same way.
All animations in Annnimate are built with useGSAP and follow these exact patterns. If you want production-ready React animation code without building from scratch, that's where to start.