EmpireUI
Get Pro
← Blog7 min read#gsap#react-animation#usegsap

GSAP + React: Production-Ready Animation Without Side Effects

GSAP and React don't always play nice out of the box. Here's how to wire up production-ready animations using useGSAP, refs, and context — no memory leaks, no jank.

Abstract motion blur of colorful light trails representing animation and motion in web development

Why GSAP Still Wins Over CSS Animations in React Apps

Honestly, CSS animations are fine until they're not. The moment you need to sequence 12 elements, react to scroll position, or reverse a timeline on user input, you're writing spaghetti keyframe code that's nearly impossible to maintain.

GSAP (GreenSock Animation Platform) handles all of that cleanly. We're talking sub-millisecond timing control, hardware-accelerated transforms, and a timeline API that reads like English. The library has been around since 2008 and it still outperforms browser-native alternatives on complex sequences — not because it's magic, but because it batches DOM writes intelligently.

The catch is that GSAP was designed for the DOM era. Plugging it into React's render cycle without thinking about cleanup will leave you with memory leaks, duplicate animations on re-renders, and effects that fire at completely the wrong time. This article is specifically about fixing all of that.

Setting Up GSAP in a React 18+ Project

Start with the install. As of GSAP 3.12.x, the @gsap/react package ships a useGSAP hook that handles cleanup automatically. You need both packages.

npm install gsap @gsap/react

If you're on React 18 with Strict Mode enabled, you'll immediately notice animations firing twice in development. That's expected — Strict Mode mounts, unmounts, then remounts components to surface side effects. The useGSAP hook is built to survive this cycle cleanly, which is exactly why you should use it over a raw useEffect.

One more thing before you write a single animation: register any GSAP plugins you need at the module level, not inside components. Registering inside a component means it runs on every render.

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

// Do this ONCE at module level, not inside components
gsap.registerPlugin(ScrollTrigger);

The useGSAP Hook: Your New Default Pattern

The useGSAP hook from @gsap/react is a thin wrapper around useLayoutEffect that automatically collects all GSAP tweens and timelines created inside it, then kills them on cleanup. No manual tl.kill() calls. No stale closure bugs.

import { useRef } from 'react';
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';

export function HeroCard() {
  const containerRef = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // gsap.context scopes all selectors to containerRef
    gsap.from('.hero-title', {
      y: 40,
      opacity: 0,
      duration: 0.7,
      ease: 'power3.out',
    });
    gsap.from('.hero-subtitle', {
      y: 24,
      opacity: 0,
      duration: 0.6,
      delay: 0.15,
      ease: 'power2.out',
    });
  }, { scope: containerRef }); // <-- this is the key part

  return (
    <div ref={containerRef} className="relative">
      <h1 className="hero-title text-5xl font-bold">Motion matters.</h1>
      <p className="hero-subtitle text-lg text-zinc-400">Built with purpose.</p>
    </div>
  );
}

The scope option tells GSAP to treat string selectors as scoped to that ref's DOM node. This prevents your .hero-title selector from accidentally targeting a matching element elsewhere in the tree. It's the same protection you get from CSS Modules, but for animations.

Pass a dependency array as the second argument when your animation depends on state or props. Same semantics as useEffect — GSAP will kill the old animations and re-run when deps change.

Building Timeline Sequences That Don't Break on Re-render

Timelines are where GSAP really separates itself. You can stagger, overlap, and reverse complex sequences with a handful of lines. The pattern below creates a reusable entrance animation that plays once on mount.

import { useRef } from 'react';
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';

interface CardListProps {
  items: string[];
}

export function CardList({ items }: CardListProps) {
  const listRef = useRef<HTMLUListElement>(null);

  useGSAP(() => {
    const tl = gsap.timeline({ defaults: { ease: 'power2.out' } });

    tl.from('.card-item', {
      y: 32,
      opacity: 0,
      duration: 0.5,
      stagger: 0.08, // 80ms between each card
    });
  }, { scope: listRef, dependencies: [items.length] });

  return (
    <ul ref={listRef} className="flex flex-col gap-3">
      {items.map((item, i) => (
        <li key={i} className="card-item rounded-xl bg-zinc-900 p-4 text-sm">
          {item}
        </li>
      ))}
    </ul>
  );
}

Notice the 0.08 stagger value. At that speed, the stagger feels snappy without being rushed. Go above 0.15 and users start to perceive the list as loading slowly. These numbers matter more than people think.

If items.length changes, useGSAP tears down the old timeline and spins up a fresh one. You don't have to manage that lifecycle yourself.

GSAP ScrollTrigger in React: Timing Is Everything

ScrollTrigger is the plugin most people reach for, and it's also the one that causes the most confusion in React. The core problem: ScrollTrigger calculates element positions during initialization. If the DOM hasn't settled when you initialize, your trigger points will be wrong.

The fix is to use useLayoutEffect semantics, which useGSAP provides by default. It runs synchronously after the DOM is painted, before the browser shows the frame to the user. That guarantees element positions are accurate.

import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(ScrollTrigger);

export function ParallaxSection() {
  const sectionRef = useRef<HTMLElement>(null);

  useGSAP(() => {
    gsap.to('.parallax-inner', {
      y: -80,
      ease: 'none',
      scrollTrigger: {
        trigger: sectionRef.current,
        start: 'top bottom',
        end: 'bottom top',
        scrub: 1.2, // smooth 1.2s lag behind scroll
      },
    });
  }, { scope: sectionRef });

  return (
    <section ref={sectionRef} className="relative h-screen overflow-hidden">
      <div
        className="parallax-inner absolute inset-0 bg-cover bg-center"
        style={{ backgroundImage: 'url(/hero-bg.jpg)', top: '-80px', bottom: '-80px' }}
      />
    </section>
  );
}

That scrub: 1.2 value creates a slight lag between the user's scroll position and the animation progress. It reads as smoother than scrub: true (which locks to scroll exactly) because it absorbs micro-jitter from trackpads. For UI elements you want exact sync — for background layers, the lag feels physical.

Want particle or starfield effects alongside scroll animations? Pair this with shooting stars backgrounds or aurora backgrounds — both work well as the static layer behind a GSAP-driven foreground.

Avoiding the Three Most Common GSAP + React Mistakes

First mistake: animating elements before they exist. This usually shows up as a flash of unanimated content or a console error about animating null. Always confirm your ref is populated before running GSAP. Inside useGSAP, the scope ref is guaranteed to be available — but if you're using conditional rendering, add a guard.

Second mistake: creating tweens directly in render functions or event handlers without cleanup. Every gsap.to() call creates a tween object that holds a reference to a DOM node. If you create 50 tweens in an onClick handler over a session, you've got 50 live objects pointing at 50 DOM nodes. The useGSAP hook doesn't protect you here — wrap event handler animations in a gsap Context or kill them manually.

Third mistake: forgetting that gsap.set() doesn't animate, it just sets property values instantly. It's useful for resetting state before an animation plays, but developers often confuse it with gsap.to() when debugging. If your animation isn't playing, check whether you accidentally wrote gsap.set() instead.

There's also a fourth one that's less obvious: running GSAP alongside CSS transitions on the same properties. GSAP and CSS both want to own transform. They'll fight, and you'll get jittery output. Pick one or the other per element. This is especially relevant if you're using Tailwind CSS utility classes that include transition utilities like transition-transform.

Performance Tuning: Will-Change, Force3D, and Reduced Motion

By default, GSAP sets will-change: transform on elements it's animating. That's good — it promotes the element to its own compositor layer, preventing repaints. But applying will-change to too many elements simultaneously can overwhelm the GPU. Aim for fewer than 10 animated elements with compositor-layer promotion at any one time.

GSAP's force3D: true option explicitly forces GPU-composited transforms even for 2D animations. It's on by default for most transforms. If you're seeing ghosting or flickering on Safari (still happening in 2026, yes), try force3D: false on the specific tween that's misbehaving.

The most important performance consideration is respecting prefers-reduced-motion. It's not optional anymore — it's an accessibility requirement. Here's a clean pattern:

import { gsap } from 'gsap';

// Call this once at app startup
export function setupMotionPreference() {
  const prefersReduced = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;

  if (prefersReduced) {
    // globalTimeline is the root of all GSAP animations
    gsap.globalTimeline.timeScale(0); // instant, no animation
  }
}

Setting timeScale(0) on the global timeline means every GSAP animation across your entire app completes instantly. Elements end up in their final state, the UI is fully functional, and users who need reduced motion get exactly that. You can also use a smaller value like 0.1 if you want motion to be present but extremely fast rather than absent entirely.

Composing GSAP With Empire UI Components

Empire UI's animated background components like particles backgrounds and spotlight effects are built to sit in the DOM without interfering with your own animation layer. They use CSS animations or canvas-based rendering — not GSAP — so there's no conflict.

The practical pattern is to use Empire UI components for ambient, looping background effects and GSAP for purposeful, event-driven or scroll-driven foreground animations. Background atmosphere is declarative and CSS-driven. Foreground interactions are imperative and GSAP-driven. They occupy different layers and different mental models.

If you want a themed environment that changes between light and dark modes, take a look at how theme toggling works — GSAP can animate the transition between themes by interpolating CSS custom property values directly, giving you a morphing color shift instead of an abrupt swap.

The overall architecture is straightforward: Empire UI handles the visual layer, GSAP handles the motion layer, React handles the state layer. They don't need to know about each other. When your component library, animation library, and state management each stay in their lane, you end up with code that's actually possible to debug six months from now.

FAQ

Should I use useGSAP or useEffect for GSAP animations in React?

Use useGSAP from the @gsap/react package. It wraps useLayoutEffect and automatically cleans up all tweens and timelines created inside it when the component unmounts or dependencies change. A raw useEffect will work, but you'll have to manually call tl.kill() in the cleanup function every time — it's easy to forget and causes memory leaks.

Why do my GSAP animations fire twice in React Strict Mode?

React 18 Strict Mode intentionally mounts and unmounts components twice in development to expose side effects. GSAP animations run on mount, so they fire twice. This is expected. The useGSAP hook handles this correctly — it cleans up on the first unmount and re-initializes on the second mount. You won't see this in production builds.

How do I animate a component that renders conditionally with GSAP?

For enter animations, useGSAP with a dependency on the visible state works well — when the component mounts, the hook fires. For exit animations, it's trickier because React unmounts the component before GSAP can animate it out. You'll need to delay the unmount using state, or use a library like react-transition-group to hold the element in the DOM during the exit animation.

Can GSAP animate CSS custom properties (variables)?

Yes, as of GSAP 3.x. Use gsap.to(element, { '--my-var': '100px', duration: 0.5 }) and GSAP will interpolate the value. This is useful for animating theme colors or dynamic layout values without touching individual transform properties. Note that GSAP can't infer the unit type for custom properties, so you need to include units in your start and end values.

Is GSAP free for commercial projects?

The core GSAP library and most plugins (including ScrollTrigger, ScrollTo, and Draggable) are free for commercial use under the GreenSock Standard License. The premium Club GreenSock plugins like SplitText, MorphSVG, and DrawSVG require a paid membership. Check gsap.com/licensing for the current breakdown — the licensing model has been consistent since version 3.

How do I make GSAP animations respect prefers-reduced-motion?

The simplest approach is to check window.matchMedia('(prefers-reduced-motion: reduce)').matches at app startup and set gsap.globalTimeline.timeScale(0) if it's true. This makes every GSAP animation in your app complete instantly, landing elements in their final state. A more nuanced approach is to set timeScale to a small non-zero value like 0.05 so transitions still happen but are nearly instantaneous.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Particle Background in React: tsParticles and Canvas APIScroll Progress Animation: Reading Bar and Section IndicatorsTailwind Animation Library: 30 Classes for Common EffectsAurora UI Effects: Animated Northern Lights in CSS and React