# GSAP with React: useGSAP, ScrollTrigger and proper cleanup

> Learn how to use GSAP in React with useGSAP, scoped selectors, ScrollTrigger cleanup, Strict Mode, Next.js, and reduced-motion support.

Published 2026-07-26 by Jake. Canonical: https://gsapvault.com/blog/gsap-react-usegsap-scrolltrigger

---
GSAP works with React, but animations need to follow React's component lifecycle. The practical pattern is to create them with the official `useGSAP()` hook, scope selectors to a component, and let its GSAP context revert animations and ScrollTriggers during cleanup.

This guide covers that pattern from a basic tween through ScrollTrigger, reactive values, Strict Mode and the Next.js App Router.

## Install GSAP for React

Install GSAP and its official React hook:

```bash
npm install gsap @gsap/react
```

Then register the hook alongside any GSAP plugins you use:

```javascript
import gsap from "gsap";
import { useGSAP } from "@gsap/react";

// Register once at module level.
gsap.registerPlugin(useGSAP);
```

The [`@gsap/react` package](https://github.com/greensock/react) describes `useGSAP()` as a replacement for `useEffect()` or `useLayoutEffect()` for GSAP code. It uses `gsap.context()` internally and falls back to an isomorphic effect when `window` is unavailable.

## A complete GSAP React example

Here is a component that reveals three cards. The container ref provides a scope, so `.card` only selects descendants of this component instance.

```jsx
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(useGSAP);

export default function CardGrid() {
  const container = useRef(null);

  useGSAP(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      return;
    }

    gsap.from(".card", {
      y: 32,
      opacity: 0,
      duration: 0.6,
      stagger: 0.1,
      ease: "power2.out"
    });
  }, { scope: container });

  return (
    <section ref={container}>
      <article className="card">First card</article>
      <article className="card">Second card</article>
      <article className="card">Third card</article>
    </section>
  );
}
```

The content remains visible when JavaScript is unavailable or reduced motion is enabled. GSAP only applies the temporary starting state when the animation runs.

## Why useGSAP is safer than a bare useEffect

A GSAP tween is an external system from React's point of view. React does not automatically know that it should kill the tween, remove inline styles or destroy a ScrollTrigger when the component disappears.

You can manage this manually with `useLayoutEffect()` and `gsap.context()`:

```jsx
import { useLayoutEffect, useRef } from "react";
import gsap from "gsap";

export default function ManualCard() {
  const container = useRef(null);

  useLayoutEffect(() => {
    const context = gsap.context(() => {
      if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
        gsap.from(".card", { y: 32, opacity: 0 });
      }
    }, container);

    return () => context.revert();
  }, []);

  return (
    <div ref={container}>
      <article className="card">Card</article>
    </div>
  );
}
```

That is valid, but `useGSAP()` packages the same lifecycle pattern into a hook. It also defaults to an empty dependency array, preventing an animation from being recreated after every render because the array was accidentally omitted.

The choice is not really `useEffect` versus GSAP. It is manual lifecycle management versus the official hook that implements it for you.

## Scope selectors to the component

Global selectors are a common source of React animation bugs. If a page renders two instances of the same component, `gsap.from(".card")` can animate cards belonging to both.

Passing `{ scope: container }` limits selector text to descendants of that ref:

```jsx
const container = useRef(null);

useGSAP(() => {
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    return;
  }

  gsap.from(".card", {
    opacity: 0,
    y: 24,
    stagger: 0.08
  });
}, { scope: container });

return <div ref={container}>{/* Only cards inside here are selected. */}</div>;
```

You still need direct refs when an animation targets one specific DOM node or when another API needs the element. For groups of descendants, scoped selector text avoids creating a separate ref for every item.

## Use ScrollTrigger inside useGSAP

Register ScrollTrigger, then create it inside the hook. The ScrollTrigger belongs to the same context as its tween, so cleanup reverts both.

```jsx
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(useGSAP, ScrollTrigger);

export default function FeatureList() {
  const container = useRef(null);

  useGSAP(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      return;
    }

    gsap.from(".feature", {
      y: 40,
      opacity: 0,
      duration: 0.7,
      stagger: 0.12,
      ease: "power2.out",
      scrollTrigger: {
        trigger: container.current,
        start: "top 80%",
        once: true
      }
    });
  }, { scope: container });

  return (
    <section ref={container}>
      <article className="feature">Scoped</article>
      <article className="feature">Responsive</article>
      <article className="feature">Cleaned up</article>
    </section>
  );
}
```

You do not need to call `ScrollTrigger.getAll().forEach(trigger => trigger.kill())` when this component unmounts. That global approach could destroy triggers owned by unrelated components. Context cleanup is narrower: it reverts the GSAP objects created by this hook.

For a deeper explanation of trigger positions and scrubbed motion, see [Animate on Scroll: 3 Approaches Compared](/blog/how-to-animate-on-scroll).

## GSAP in Next.js App Router

In Next.js, a Server Component can render the markup, but an animation that reads the DOM needs a Client Component. Add the [`"use client"` directive](https://nextjs.org/docs/app/api-reference/directives/use-client) at the top of the animated component's file.

```jsx
"use client";

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

gsap.registerPlugin(useGSAP);

export default function AnimatedHero() {
  const hero = useRef(null);

  useGSAP(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      return;
    }

    gsap.from(".hero-content > *", {
      y: 28,
      opacity: 0,
      duration: 0.7,
      stagger: 0.1,
      ease: "power3.out"
    });
  }, { scope: hero });

  return (
    <header ref={hero} className="hero-content">
      <p>React animation</p>
      <h1>GSAP in a Client Component</h1>
      <a href="#content">Explore</a>
    </header>
  );
}
```

The whole route does not need to become a Client Component. Keep the client boundary around the smallest interactive region that needs browser APIs, state or effects. Data fetching and static content can remain in Server Components and pass serializable props into the animated component.

## React Strict Mode and animations that appear to run twice

In development, [React Strict Mode](https://react.dev/reference/react/StrictMode) runs an extra setup and cleanup cycle for effects. This is intended to expose missing cleanup. It does not happen in the same way in a production build.

If a tween, listener or ScrollTrigger survives that test cycle, the next setup creates another one. The visible symptom is often a doubled animation or duplicate callback.

Do not remove Strict Mode to hide the problem. Create the animation inside `useGSAP()` and keep delayed animation creation context-safe. A correctly reverted context should survive React's development check without leaking the first setup.

## Rebuild animations when React values change

Some animations depend on props or state. Add those values to `dependencies`, and use `revertOnUpdate: true` when the old animation should be reverted before a new one is created.

```jsx
function MovingMarker({ endX }) {
  const container = useRef(null);

  useGSAP(() => {
    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    gsap.to(".marker", {
      x: endX,
      duration: reduceMotion ? 0 : 0.6,
      ease: "power2.out"
    });
  }, {
    scope: container,
    dependencies: [endX],
    revertOnUpdate: true
  });

  return <div ref={container}><span className="marker" /></div>;
}
```

Without `revertOnUpdate`, GSAP objects are still reverted when the hook is torn down, but not automatically on every dependency update. That can be useful when an animation should persist while a component remains mounted. Use the option when each value needs a clean replacement animation.

Avoid putting frequently changing animation progress into React state. If a value updates on every pointer move or animation frame, a ref, `gsap.quickTo()` or GSAP's own callbacks usually avoids unnecessary React renders.

## Make event handlers context-safe

The main `useGSAP()` callback is automatically recorded by its context. An animation created later inside a click handler, timeout or subscription is not recorded unless you wrap that callback with `contextSafe()`.

```jsx
function RotatingButton() {
  const container = useRef(null);
  const { contextSafe } = useGSAP({ scope: container });

  const rotate = contextSafe(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      return;
    }

    gsap.to(".icon", {
      rotation: "+=180",
      duration: 0.4,
      ease: "power2.inOut"
    });
  });

  return (
    <button ref={container} type="button" onClick={rotate}>
      <span className="icon" aria-hidden="true">↻</span>
      Refresh
    </button>
  );
}
```

`contextSafe()` records GSAP objects created when the wrapped function eventually executes. React handles removal of the JSX `onClick` handler; if you add native event listeners yourself, return a cleanup function that removes them too.

## Handle reduced motion without hiding content

The safest default is visible content. Apply an entrance animation only when the user has not requested reduced motion. For several related media queries, `gsap.matchMedia()` keeps setup and cleanup together.

```jsx
useGSAP(() => {
  const media = gsap.matchMedia();

  media.add({
    reduceMotion: "(prefers-reduced-motion: reduce)",
    allowMotion: "(prefers-reduced-motion: no-preference)"
  }, (context) => {
    const { reduceMotion } = context.conditions;

    if (reduceMotion) {
      // Content uses its normal, visible CSS state.
      return;
    }

    gsap.from(".card", {
      y: 32,
      opacity: 0,
      stagger: 0.1,
      duration: 0.6
    });
  });

  return () => media.revert();
}, { scope: container });
```

The [`prefers-reduced-motion` media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) reflects an operating-system preference. Reduced motion does not always mean removing every transition, but large movement, parallax and scrubbed sequences should be removed or replaced with an immediate state change.

## Refresh ScrollTrigger after layout changes

ScrollTrigger measures positions when it refreshes. Fonts, images, accordions and asynchronously loaded content can change the layout after those measurements.

Call `ScrollTrigger.refresh()` after a known layout change rather than on every render:

```jsx
function ExpandingSection() {
  const [open, setOpen] = useState(false);

  useLayoutEffect(() => {
    if (open) {
      const frame = requestAnimationFrame(() => ScrollTrigger.refresh());
      return () => cancelAnimationFrame(frame);
    }
  }, [open]);

  return (
    <section>
      <button type="button" onClick={() => setOpen(value => !value)}>
        Toggle details
      </button>
      {open && <div>Content that changes the document height</div>}
    </section>
  );
}
```

This example does not create an animation; it updates ScrollTrigger's measurements after React commits the expanded content. Images can use the same idea in an `onLoad` handler. Avoid attaching a new resize or scroll listener inside every component—ScrollTrigger already handles standard viewport changes.

## Common GSAP React problems

| Problem | Likely cause | Fix |
|---|---|---|
| Animation runs after every render | Missing dependency configuration | Use `useGSAP()` with no dependencies, or declare them explicitly |
| Two component instances animate together | Global selector text | Pass a container ref as `scope` |
| Duplicate triggers in development | Missing cleanup exposed by Strict Mode | Create triggers inside `useGSAP()` |
| Click animations survive unmounting | Delayed callback is outside the context | Wrap it with `contextSafe()` |
| Scroll positions are wrong | Layout changed after measurement | Refresh after fonts, images or dynamic content settle |
| Next.js reports a browser API or hook error | Animation is crossing the server boundary | Move it into a file marked `"use client"` |
| Content stays invisible with reduced motion | CSS hides it before JavaScript runs | Keep the default state visible and apply starting states through GSAP |

## A practical React checklist

Before shipping a GSAP React animation, check that:

1. `gsap`, `useGSAP` and any plugins are registered once at module level.
2. The animation lives inside `useGSAP()` rather than the render function.
3. Selector text is scoped to a container ref.
4. Props and state used by the animation appear in `dependencies`.
5. `revertOnUpdate` is enabled when updates should replace the old animation.
6. Delayed callbacks are wrapped with `contextSafe()`.
7. Native event listeners and non-GSAP subscriptions have their own cleanup.
8. ScrollTrigger is refreshed after known asynchronous layout changes.
9. The content works without JavaScript and with reduced motion enabled.
10. Development has been tested with Strict Mode left on.

## GSAP React FAQ

### Does GSAP work with React?

Yes. GSAP is framework-agnostic. The official `@gsap/react` package adds the `useGSAP()` hook to scope animations and clean them up when a React component unmounts or updates.

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

Use `useGSAP()` for component animations in most React projects. It handles isomorphic layout effects, selector scoping and GSAP context cleanup. A manually written effect can work, but you must implement those safeguards yourself.

### Why does my GSAP animation run twice in React Strict Mode?

React Strict Mode runs an extra development-only setup and cleanup cycle to reveal missing cleanup. If the animation is created with `useGSAP()`, its context is reverted during cleanup before React runs the final setup.

### How do I use GSAP with Next.js?

Put animation code in a Client Component marked with the `"use client"` directive, then initialize it with `useGSAP()`. Server Components can render animated markup, but browser-dependent GSAP code must run on the client.

### Does useGSAP clean up ScrollTrigger?

Yes. ScrollTriggers created during the `useGSAP()` callback are recorded by its GSAP context and reverted when that context is cleaned up. Wrap delayed callbacks and event handlers with `contextSafe()` so their animations are recorded too.

## Conclusion

GSAP does not need a React-specific animation engine. It needs a reliable bridge into React's lifecycle, which is what `useGSAP()` provides.

Create animations inside the hook, scope selectors to the component, declare reactive dependencies and use `contextSafe()` for work that happens later. In Next.js, keep browser-dependent animation code inside a Client Component. These patterns prevent most duplicate tweens, stale selectors and leaked ScrollTriggers before they reach production.

For performance beyond component cleanup, read [Why GSAP animations get janky and how to fix it](/blog/gsap-performance-guide).

---

*Looking for production-ready GSAP effects? Browse the [GSAP Vault effects library](/effects) for scroll animations, text effects, and interactive components you can adapt to React, Next.js or another stack. Every download also includes an optional [AI setup guide](/vibe-coding) for integrating the effect into an existing project.*

## Sources and further reading

- [Using GSAP with React](https://gsap.com/resources/React/) — official GSAP guide
- [`@gsap/react` README](https://github.com/greensock/react) — hook configuration, cleanup and `contextSafe()`
- [React Strict Mode](https://react.dev/reference/react/StrictMode) — development-only effect checks
- [Next.js `use client` directive](https://nextjs.org/docs/app/api-reference/directives/use-client) — Client Component boundaries
- [MDN: `prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) — reduced-motion media feature

## Related articles

- **[Animate on Scroll: 3 Approaches Compared](/blog/how-to-animate-on-scroll)** — CSS scroll animations, Intersection Observer and ScrollTrigger compared.
- **[Why GSAP animations get janky and how to fix it](/blog/gsap-performance-guide)** — compositor-friendly properties, ScrollTrigger performance and cleanup.
- **[Using AI to Customize GSAP Effects](/blog/using-ai-assistants-gsap-effects)** — prompts and workflows for adapting effect code to React and other environments.