The Core Problem: Zombie ScrollTriggers
When orchestrating complex pinned animations in single-page applications, route transitions frequently leave dangling ScrollTrigger instances attached to DOM nodes that no longer exist. In React 19, strict concurrency and component lifecycle timing make manual tween tracking prone to race conditions.
The Solution: gsap.context() as a Lifetime Boundary
By wrapping every timeline and trigger inside gsap.context(), you establish an isolated execution boundary. Calling ctx.revert() in the cleanup callback of your useEffect guarantees that all child timelines are stopped, inline styles applied by GSAP are rolled back to their initial state, and ScrollTriggers are unregistered.
useEffect(() => {
const ctx = gsap.context(() => {
// All selectors inside are automatically scoped
gsap.to(".anim-target", {
scrollTrigger: {
trigger: "#section",
start: "top top",
end: "+=100%",
scrub: true,
pin: true,
},
scale: 1.2,
opacity: 0,
});
});
return () => {
// Reverts inline styles and unregisters all ScrollTriggers cleanly
ctx.revert();
};
}, []);Coordinating Lenis and ScrollTrigger
When integrating smooth scrolling via Lenis, never let Lenis and GSAP run separate animation frames. Wire Lenis into GSAP's central ticker so that scroll position updates and timeline calculations execute synchronously in the exact same microtask tick.
const lenis = new Lenis({ lerp: 0.08 });
lenis.on("scroll", ScrollTrigger.update);
const tickerCallback = (time: number) => {
lenis.raf(time * 1000);
};
gsap.ticker.add(tickerCallback);
gsap.ticker.lagSmoothing(0);Mobile Viewport Resizing (Address Bar Collapse)
Mobile WebKit resizes the inner height as the user scrolls, triggering ScrollTrigger recalculations that cause jarring visual stutter. Call ScrollTrigger.normalizeScroll(true) on touch devices to lock scroll containment and eliminate address bar jitter.