GSAP ScrollTrigger Not Working in Next.js? Start Here
Triggers firing at the wrong spot, animations running twice, or nothing at all? In a React or Next.js app it almost always comes down to one of these six things.
ScrollTrigger itself is rock solid. When it misbehaves inside a React or Next.js app, the cause is nearly always how it's wired into React. Work through this list top to bottom.
1. The file isn't a client component
In the App Router every component is a server component unless it says otherwise, and server components never run effects. Put "use client" at the very top of any file that sets up GSAP.
2. The plugin was never registered
Register every plugin once, at the top of the module, before anything animates. Registering twice is harmless.
3. Animations are created outside useGSAP
In development React mounts components twice, so anything created in a plain useEffect without cleanup runs twice: duplicate triggers, markers stacked on markers. The useGSAP hook from @gsap/react records every tween and trigger you create and reverts them all on unmount.
"use client";
import { useRef, type ReactNode } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger);
export function Reveal({ children }: { children: ReactNode }) {
const root = useRef<HTMLDivElement>(null);
useGSAP(
() => {
gsap.from(".item", {
y: 40,
opacity: 0,
stagger: 0.1,
scrollTrigger: { trigger: root.current, start: "top 80%" },
});
},
{ scope: root }, // ".item" only matches inside this component
);
return <div ref={root}>{children}</div>;
}4. Start and end positions are measured too early
ScrollTrigger measures the page when it's created. If images without a fixed size load afterwards, the page grows and every trigger below them is off. Give images a width and height (or an aspect ratio), or call ScrollTrigger.refresh() once they've loaded.
5. A smooth-scroll library isn't connected
Lenis and similar libraries move the page their own way, so ScrollTrigger has to be told when the scroll changes: lenis.on("scroll", ScrollTrigger.update). Our Lenis guide has the full setup.
6. A transformed parent breaks pinning
Pinning uses position: fixed, and a fixed element inside a parent with a transform, filter or will-change: transform is fixed to that parent, not the screen. Remove the transform from the ancestors, or pass pinType: "transform".