| name | gsap-web |
| description | GSAP in a React or Next.js app without the leaks -- timelines over stacked tweens, useGSAP and context for cleanup, ScrollTrigger set up so it survives route changes and layout shifts. Use when building GSAP animations in a component-based app. |
GSAP for Web Apps
GSAP is straightforward until it meets React. Then every animation that worked in a CodePen starts double-firing in Strict Mode, surviving unmount, and pointing at stale DOM after a route change. The library is not the problem -- lifecycle is. These are the patterns that hold up.
Setup
Install gsap and @gsap/react. Register plugins once, at module scope in a client component, never inside render:
'use client'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(ScrollTrigger, useGSAP)
gsap.defaults({ duration: 0.28, ease: 'power3.out' })
In Next.js App Router, anything touching GSAP is a client component. GSAP reads the DOM, so it cannot run during SSR -- a window is not defined build error means the import climbed into a server component.
Workflow
-
Scope everything. useGSAP with a container ref. It reverts every animation created inside on unmount and handles Strict Mode's double-invoke, which is the source of nearly every "it fires twice" report.
const container = useRef(null)
useGSAP(() => {
gsap.from('.card', { y: 24, opacity: 0, stagger: 0.05 })
}, { scope: container })
Selector strings inside the callback resolve within the scope, so .card cannot reach into another component's markup.
-
Timelines, not stacked tweens. Three sequenced tweens with hand-computed delays become unmaintainable the moment one duration changes. One timeline with the position parameter stays readable:
const tl = gsap.timeline()
tl.from('.title', { y: 30, opacity: 0 })
.from('.sub', { y: 20, opacity: 0 }, '-=0.15')
.from('.cta', { scale: 0.96, opacity: 0 }, '<')
Use labels (tl.addLabel('reveal')) once a timeline passes about five steps.
-
vs . animates , which is fragile when other code or a re-render changes that state -- an interrupted can leave the element stuck at its start values. For anything that can be interrupted or replayed, use and state both ends explicitly. Add when a inside a timeline flashes its end state on load.
Rules
-
Animate x/y/scale/rotation/opacity. GSAP's x/y compile to transform -- animating top/left instead costs layout on every frame.
-
Never animate in a useEffect without cleanup. useGSAP exists precisely so you do not have to remember ctx.revert().
-
Set will-change through GSAP's own force3D behavior or not at all; a permanent will-change: transform on many elements is a memory cost, not a speedup.
-
Kill markers: true before committing. It ships to production more often than you would think.
-
Respect reduced motion at the source with gsap.matchMedia(), so the reduced branch is a real alternative timeline rather than a disabled one:
const mm = gsap.matchMedia()
mm.add('(prefers-reduced-motion: no-preference)', () => { })
mm.add('(prefers-reduced-motion: reduce)', () => { })
-
Do not reach for GSAP when CSS suffices. A hover state, a fade-in, a spinner -- those are transitions and keyframes. GSAP earns its bundle on sequenced timelines, scroll scrubbing, and anything needing runtime control.
-
GSAP's ScrollSmoother, SplitText, and other formerly-paid plugins are free as of GSAP 3.13, but every plugin is bundle weight -- add one only when the effect is core to the page.