GSAP is one of the most capable animation libraries on the web, and React is one of the most popular ways to build interfaces. Used together carelessly, though, they step on each other: animations run twice, elements get stuck invisible, and ScrollTriggers pile up between page navigations. These are the patterns we use to keep the two working together.
1. Let useGSAP handle the lifecycle
The official @gsap/react package provides a useGSAP hook. It behaves like an effect, but every animation and ScrollTrigger created inside it is recorded in a GSAP context and reverted automatically when the component unmounts. That one detail removes most of the cleanup bugs people run into.
"use client";
import { useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export function Reveal({ children }: { children: React.ReactNode }) {
const scope = useRef<HTMLDivElement>(null);
useGSAP(
() => {
gsap.from(".item", { y: 32, autoAlpha: 0, stagger: 0.08 });
},
{ scope }, // selectors only match inside this element
);
return <div ref={scope}>{children}</div>;
}The scope option matters as much as the cleanup. Without it, a selector like .item matches every element on the page with that class — including ones owned by other components.
2. Expect Strict Mode to run your code twice
In development, React Strict Mode mounts, unmounts and remounts components to surface side-effect bugs. An animation written with a bare useEffect and no cleanup will start twice, and gsap.from() will often capture the half-animated state as its starting point. Because useGSAP reverts on unmount, the second run starts from a clean slate.
3. Never let content depend on JavaScript to appear
A common pattern is to hide elements with CSS (opacity: 0) and then animate them in. If the script fails to load, or a visitor has reduced motion enabled, that content is simply gone. A safer approach is to opt in to the hidden state only when animation is actually going to run.
- Run a tiny inline script before first paint that adds a class such as motion-ok to the html element — but only when prefers-reduced-motion is not set.
- Scope your initial hidden styles to that class: html.motion-ok [data-reveal] { opacity: 0 }.
- Add a fallback timer that removes the class if the animation runtime hasn't started after a few seconds.
4. Watch out for transforms set in CSS
GSAP reads an element's existing transform and parses it into its own x and y values. If your CSS pre-hides a line with transform: translateY(110%) and you then animate yPercent back to 0, GSAP may preserve the parsed pixel offset — and the text stays hidden. Set both explicitly in a fromTo tween so the result doesn't depend on what was parsed.
gsap.fromTo(
"[data-line] > span",
{ yPercent: 110, y: 0 },
{ yPercent: 0, y: 0, duration: 1.2, stagger: 0.1, ease: "expo.out" },
);5. Refresh ScrollTrigger when layout settles
ScrollTrigger calculates start and end positions when it's created. Web fonts that load late, images without dimensions, and content that expands after hydration all shift the layout and make those positions stale. Give images explicit sizes, and call ScrollTrigger.refresh() once fonts are ready.
document.fonts?.ready.then(() => ScrollTrigger.refresh());6. Animate the cheap properties
Transforms and opacity can be handled by the compositor without recalculating layout. Animating width, height, top or margin forces layout work on every frame, which is exactly what stutters on a mid-range phone. If an effect seems to need a layout property, there is usually a transform-based way to get the same look.
Summary
- Use useGSAP with a scope for automatic, contained cleanup.
- Assume effects run twice in development.
- Only hide content when animation will definitely run.
- Use explicit fromTo values when CSS also sets transforms.
- Refresh ScrollTrigger after fonts and late content settle.
- Prefer transform and opacity for smooth motion.