Skip to content

ScrollTrigger in the App Router without the jank

Server components, streaming and client navigation each break a scroll animation in their own way. Four rules that make GSAP behave in a Next.js app.

Published
Reading time
3 min read
Filed under
Animation, Next.js
Author
Bibaswan Prasai

Scroll-driven animation assumes a stable document: elements have measured positions, those positions stay put, and the page you measured is the page the user scrolls. The App Router violates all three at least once per navigation. Most “ScrollTrigger is broken in Next.js” reports come down to four fixable mistakes.

Register once, on the client only

ScrollTrigger touches window at import time. Registering it inside a component body means re-registering on every render; registering it in a file that a server component imports means a build error. Put it in one client module and import that module everywhere else.

lib/gsap.ts
"use client";
 
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
 
if (typeof window !== "undefined") {
  gsap.registerPlugin(ScrollTrigger);
  ScrollTrigger.config({ ignoreMobileResize: true });
}
 
export { gsap, ScrollTrigger };

ignoreMobileResize matters more than it looks. On phones the URL bar collapses as you scroll, which fires a resize event, which makes ScrollTrigger recalculate every start and end position mid-scroll. The animation appears to stutter exactly once, near the top of the page, and only on real devices.

Scope every animation to a context

gsap.context() records everything created inside it — tweens, timelines and the ScrollTriggers attached to them — so a single revert() removes all of it. Without it, a component that unmounts leaves live triggers pointing at detached DOM nodes, and they accumulate across client navigations.

components/Reveal.tsx
useLayoutEffect(() => {
  const root = ref.current;
  if (!root) return;
 
  const ctx = gsap.context(() => {
    gsap.from(".line", {
      yPercent: 100,
      stagger: 0.08,
      scrollTrigger: { trigger: root, start: "top 80%" },
    });
  }, root); // <- scope: ".line" only matches inside root
 
  return () => ctx.revert();
}, []);

Two things happen here. The selector string is scoped to root, so the same component can appear twice on a page without the instances fighting over each other’s elements. And the cleanup is a single call that cannot forget a trigger you added later.

Use useLayoutEffect, not useEffect. Effects run after paint, which is one frame of the un-animated end state — a visible flash on anything that starts at opacity: 0.

Refresh after navigation, not after render

Client navigation swaps the DOM without a document load, so ScrollTrigger’s cached measurements belong to the previous page. template.tsx remounts on every navigation, which makes it the right place to force a recalculation:

app/template.tsx
"use client";
 
export default function Template({ children }) {
  useLayoutEffect(() => {
    ScrollTrigger.refresh();
  }, []);
 
  return <div>{children}</div>;
}

Refreshing more often than that is a trap. refresh() reads layout for every registered trigger, so calling it on scroll or on every render turns a smooth page into a layout-thrashing one.

Images are the other half of this problem. A trigger measured before an image loads is measured against the wrong page height. Give media an explicit aspect ratio so the reserved space is correct before the bytes arrive — the same change that fixes your CLS score fixes your scroll positions.

Branch on media queries with matchMedia

A pinned section that works at 1440px is usually wrong at 390px: the pin distance is a screenful of dead scroll, and the horizontal track has nowhere to go. gsap.matchMedia() sets up and tears down whole animation branches as the query changes, including on device rotation.

const mm = gsap.matchMedia();
 
mm.add("(min-width: 1024px)", () => {
  ScrollTrigger.create({ trigger: section, pin: true, scrub: true });
});
 
mm.add("(prefers-reduced-motion: reduce)", () => {
  gsap.set(".line", { clearProps: "all" });
});
 
return () => mm.revert();

The reduced-motion branch is not optional decoration. Scroll-linked motion is one of the specific triggers the preference exists for, and honouring it costs one condition.

The short version

  • One client module registers the plugin, everything imports from there.
  • Every animation lives in a gsap.context() scoped to its root element.
  • ScrollTrigger.refresh() belongs in template.tsx, not in a scroll handler.
  • Responsive and reduced-motion behaviour are matchMedia branches, not if statements you forget to clean up.

Animate transform and opacity, keep the trigger count proportional to sections rather than elements, and the result stays at frame rate on a mid-range phone — which is the only benchmark that matters.