GSAP ScrollTrigger in Next.js App Router: A Practical Guide
Pinned sections, horizontal scroll, and scrub animations that survive React strict mode, hydration, and route changes.
The three ways ScrollTrigger breaks in Next.js
GSAP and React have an uneasy relationship. In the App Router you will hit all three of these:
- Strict mode double-invocation creates duplicate triggers.
- Hydration measures layouts before fonts and images settle.
- Route changes leave orphaned pinned spacers in the DOM.
The pattern that fixes all three
Wrap everything in gsap.context() inside useLayoutEffect, and revert it on cleanup:
useLayoutEffect(() => {
const ctx = gsap.context(() => {
gsap.to(track, {
x: -total,
ease: "none",
scrollTrigger: {
trigger: section,
scrub: 1,
pin: true,
end: () => `+=${total}`,
invalidateOnRefresh: true,
},
});
}, section);
return () => ctx.revert();
}, []);
ctx.revert() kills every trigger and undoes every inline style GSAP wrote — no orphans, no doubles.
Measuring after things settle
Use a function for end and set invalidateOnRefresh: true so measurements re-run on refresh. For image-heavy tracks, call ScrollTrigger.refresh() once images load.
Respect reduced motion
A pinned horizontal scroll is a vestibular nightmare for some users. Check prefers-reduced-motion and fall back to a static grid — your animation should be progressive enhancement, not a requirement.