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.
"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.
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:
"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 intemplate.tsx, not in a scroll handler.- Responsive and reduced-motion behaviour are
matchMediabranches, notifstatements 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.