GSAP is still the most capable animation library on the web, and since version 3.13 every plugin — ScrollTrigger, SplitText, MorphSVG, all of them — is free. But React renders, re-renders, unmounts and (in development) runs effects twice, and an animation library that manipulates the DOM directly needs a little care to fit in.
This is the setup I use on Next.js App Router projects, this site included.
Install
npm install gsap @gsap/react@gsap/react provides the useGSAP hook, which handles the part people usually get wrong: cleanup.
The basic pattern
'use client'
import { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(useGSAP)
export function Hero() {
const container = useRef<HTMLDivElement>(null)
useGSAP(
() => {
gsap.from('.hero-line', { yPercent: 100, opacity: 0, stagger: 0.08, duration: 0.9, ease: 'power3.out' })
},
{ scope: container },
)
return (
<div ref={container}>
<h1 className="hero-line">Design</h1>
<h1 className="hero-line">and build</h1>
</div>
)
}Three things are happening:
'use client'. GSAP touches the DOM, so the component must run in the browser. Keep the animated part small and leave the rest of the page as Server Components.scope: container. Selector strings like.hero-lineonly match inside this component, so two instances never animate each other's elements.- Automatic cleanup. Every tween, timeline and ScrollTrigger created inside the callback is reverted when the component unmounts. That's also why React's development double-invocation doesn't leave duplicate animations behind.
Re-running on changes
Pass dependencies to re-run when data changes, and revertOnUpdate if the previous run should be undone first:
useGSAP(
() => {
gsap.to('.bar', { scaleX: progress, transformOrigin: 'left', duration: 0.4 })
},
{ scope: container, dependencies: [progress] },
)Event handlers: contextSafe
Animations created after the hook runs — in a click handler, say — aren't recorded for cleanup automatically. Wrap those functions in contextSafe:
const { contextSafe } = useGSAP({ scope: container })
const onEnter = contextSafe((event: React.MouseEvent<HTMLElement>) => {
gsap.to(event.currentTarget, { scale: 1.04, duration: 0.3 })
})
return <button onMouseEnter={onEnter}>Hover me</button>ScrollTrigger
Register it once (a module-level call is fine) and create triggers inside useGSAP:
'use client'
import { useRef } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(useGSAP, ScrollTrigger)
export function RevealSection({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLElement>(null)
useGSAP(
() => {
gsap.utils.toArray<HTMLElement>('.reveal').forEach((el) => {
gsap.from(el, {
y: 40,
opacity: 0,
duration: 0.8,
ease: 'power2.out',
scrollTrigger: { trigger: el, start: 'top 85%' },
})
})
},
{ scope: ref },
)
return <section ref={ref}>{children}</section>
}Pinning and scrubbing
useGSAP(
() => {
gsap.timeline({
scrollTrigger: {
trigger: '.story',
start: 'top top',
end: '+=200%',
scrub: 1, // follows the scrollbar, with 1 s of smoothing
pin: true, // holds the section while the timeline plays
},
})
.to('.story-image', { scale: 1.2 })
.to('.story-text', { yPercent: -50 }, '<')
},
{ scope: ref },
)Refresh after layout changes
ScrollTrigger measures positions when it's created. If images, fonts or late content change the page height afterwards, start and end positions drift. Call ScrollTrigger.refresh() once they're in:
document.fonts.ready.then(() => ScrollTrigger.refresh())Giving images explicit dimensions (or aspect-ratio) prevents most of this in the first place.
Next.js-specific pitfalls
Route changes and scroll position
With the App Router, navigating between pages unmounts the old page's components (so useGSAP cleans up their triggers) and mounts the new ones. If you see triggers firing at the wrong positions after navigating, it's usually because the new page measured before its images loaded — refresh after load, as above.
Server Components can't animate
You can't put useGSAP in a Server Component. Instead of making a whole page a Client Component, wrap only the animated part in a small client wrapper and pass server-rendered content in as children. The content stays in the HTML (good for SEO and LCP), the motion is added on top.
Avoid a flash of unanimated content
gsap.from() runs after hydration, so for a frame the element appears in its final state, then jumps to the start state. Two fixes:
- Use
gsap.fromTo()with CSS that sets the start state (opacity: 0in the stylesheet), and accept that the element depends on JavaScript to appear — only for non-critical elements. - Better for anything above the fold: animate from a visible state (a small
yoffset, no opacity change), so the first paint is already correct. Your LCP will thank you — see Core Web Vitals for animated websites.
Accessibility: matchMedia
gsap.matchMedia() lets you create animations only when a media query matches, and reverts them automatically when it stops matching:
useGSAP(
() => {
const mm = gsap.matchMedia()
mm.add('(prefers-reduced-motion: no-preference)', () => {
gsap.from('.card', { y: 60, opacity: 0, stagger: 0.1, scrollTrigger: '.cards' })
})
},
{ scope: container },
)Visitors who prefer reduced motion get the content without the movement. The same API handles desktop-only effects ('(min-width: 768px)').
Performance tips
- Animate
x,y,scale,rotationandopacity; GSAP maps them to transforms. - Use
gsap.quickTo()for values that update constantly, like a cursor follower — it reuses one tween instead of creating a new one per event. - Keep the number of simultaneous ScrollTriggers reasonable; batch similar elements with
ScrollTrigger.batch().
Summary
useGSAPwith ascopefor every animated componentcontextSafefor animations created in event handlers- Register plugins once; create ScrollTriggers inside
useGSAP - Refresh ScrollTrigger after fonts and images
- Keep content in Server Components and animate it from client wrappers
- Respect
prefers-reduced-motionwithgsap.matchMedia()

