Back to the blog

GSAP in React and Next.js: useGSAP, Cleanup and ScrollTrigger

GSAP in React and Next.js: useGSAP, Cleanup and ScrollTrigger

5 October 2026 — Animation

Published

5 October 2026

Reading time

3 min read

Category

Animation

Tags

  • GSAP
  • React
  • Next.js
  • ScrollTrigger
  • Animation

(Intro)How to use GSAP in React and the Next.js App Router without memory leaks or double animations. useGSAP, scoping, contextSafe, ScrollTrigger and route changes.

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

bash
npm install gsap @gsap/react

@gsap/react provides the useGSAP hook, which handles the part people usually get wrong: cleanup.

The basic pattern

components/hero.tsx
'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:

  1. '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.
  2. scope: container. Selector strings like .hero-line only match inside this component, so two instances never animate each other's elements.
  3. 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:

tsx
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:

tsx
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:

components/reveal-section.tsx
'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

tsx
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:

ts
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: 0 in 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 y offset, 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:

tsx
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, rotation and opacity; 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

  • useGSAP with a scope for every animated component
  • contextSafe for 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-motion with gsap.matchMedia()

Newsletter

Get new posts by email

A short email when something new is up. No spam, and you can unsubscribe at any time.

Design Systems for Small Teams: Start With Tokens
Design Systems for Small Teams: Start With Tokens
Next post →