GSAP is the most capable animation library in the JavaScript ecosystem. Next.js is the framework I reach for on almost every project. Getting them to work together cleanly took me longer than I'd like to admit.
This is the setup I've landed on after building animation-heavy interfaces with both.
The hydration problem
The first thing you'll hit: GSAP reads DOM geometry (getBoundingClientRect, offsetHeight, etc.) at animation time. But Next.js renders on the server, where there's no DOM. If you initialize GSAP in the wrong place, you'll get hydration mismatches or silent failures.
The rule is simple: all GSAP code goes inside useLayoutEffect or useEffect, never at the module level.
// ❌ Don't do this
gsap.registerPlugin(ScrollTrigger);
const tl = gsap.timeline(); // runs on the server
// ✅ Do this
useLayoutEffect(() => {
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
// your animations
}, containerRef);
return () => ctx.revert(); // cleanup
}, []);
Use gsap.context() always
gsap.context() is the most underused GSAP feature. It scopes all animations to a container element, which means:
- Cleanup is a single
ctx.revert()call no chasing down individual tweens - Selector strings work relative to the container, not the whole document
- React strict mode (which double-invokes effects) won't leave zombie animations
const containerRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const ctx = gsap.context(() => {
gsap.from(".card", {
opacity: 0,
y: 60,
stagger: 0.1,
scrollTrigger: {
trigger: ".card",
start: "top 80%",
},
});
}, containerRef); // scope to containerRef
return () => ctx.revert();
}, []);
ScrollTrigger and Locomotive Scroll
If you're using Locomotive Scroll (or any virtual scroll library), native ScrollTrigger won't work out of the box it listens to the window scroll event, but Locomotive intercepts that and transforms elements via CSS instead.
The fix is to proxy Locomotive's scroll position into ScrollTrigger:
locoScroll.on("scroll", ScrollTrigger.update);
ScrollTrigger.scrollerProxy(containerRef.current, {
scrollTop(value) {
if (arguments.length && locoScroll) {
locoScroll.scrollTo(value as number, { duration: 0, disableLerp: true });
}
return locoScroll ? locoScroll.scroll.instance.scroll.y : 0;
},
getBoundingClientRect() {
return {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight,
};
},
});
Then call ScrollTrigger.refresh() after Locomotive finishes its initial render.
The quickTo pattern for interactive animations
For cursor followers, magnetic effects, or anything that tracks mouse position, don't use a standard tween use quickTo. It creates an optimized setter that skips GSAP's overhead for rapid-fire updates.
const xTo = gsap.quickTo(elementRef.current, "x", {
duration: 0.6,
ease: "power3",
});
const yTo = gsap.quickTo(elementRef.current, "y", {
duration: 0.6,
ease: "power3",
});
const handleMouseMove = (e: MouseEvent) => {
xTo(e.clientX);
yTo(e.clientY);
};
The difference in smoothness compared to gsap.to() in a mousemove handler is immediately noticeable.
Performance checklist
Before shipping any GSAP-heavy page:
- [ ] All ScrollTrigger instances are killed in the cleanup function
- [ ]
will-change: transformis set on animated elements - [ ] Animations only transform
opacityandtransformneverwidth,height,top,left - [ ]
gsap.ticker.lagSmoothing(0)is called to avoid jumpy animations after browser throttling - [ ] Pin spacers aren't accumulating across route changes (call
ScrollTrigger.refresh()on route change)
GSAP is worth the learning curve. Once you internalize the context/cleanup pattern, animation-heavy UIs become surprisingly maintainable.