GSAP ScrollTrigger in React: The Patterns That Actually Work

Technical PM + Software Engineer
ScrollTrigger demos are easy. Reliable production behavior is the hard part.
Animation failures in React are rarely “GSAP bugs.” They are usually lifecycle and ownership bugs:
- duplicate triggers after remounts,
- stale measurements after async layout changes,
- desktop pin/scrub behavior shipped unchanged to mobile,
- route transitions leaving orphaned animation state.
This guide focuses on battle-tested patterns that keep ScrollTrigger stable in real React and Next.js apps.
Core principle: animation ownership must be explicit
Every animation needs a clear owner contract:
- where setup runs,
- where cleanup runs,
- what DOM scope it controls,
- when responsive variants switch,
- when measurements are recalculated.
If ownership is implicit, regressions are guaranteed over time.
Pattern 1: scope all selectors to a component root
Global selectors are one of the fastest ways to create cross-page bugs.
const root = React.useRef<HTMLDivElement>(null);
useGSAP(() => {
gsap.from(".feature", {
y: 24,
opacity: 0,
stagger: 0.08,
scrollTrigger: {
trigger: ".grid",
start: "top 80%",
once: true,
},
});
}, { scope: root });
This keeps selectors component-local and avoids accidental collisions with similarly named classes elsewhere.
Pattern 2: cleanup is a correctness requirement, not optimization
Always create animations in a GSAP context (useGSAP / gsap.context) and always revert on unmount.
Without strict cleanup you get:
- memory growth,
- doubled listeners,
- duplicated triggers after navigation,
- inconsistent behavior after hot reload or route transitions.
Treat cleanup as part of the functional contract.
Pattern 3: use motion tiers, not one animation strategy everywhere
Not every section should use pinning and scrub timelines.
Use a tiered approach:
- Tier 1: simple reveal-once animations for most content.
- Tier 2: lightweight timeline sequences for high-value sections.
- Tier 3: pin/scrub narratives only where they deliver clear UX value.
This improves performance and reduces maintenance complexity.
Pattern 4: prefer timelines over scattered tweens
Timelines encode sequencing intentionally and make changes safer.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".story",
start: "top top",
end: "+=1000",
scrub: true,
pin: true,
},
});
tl.from(".step-1", { opacity: 0, y: 30 })
.to(".step-1", { opacity: 0, y: -20 })
.from(".step-2", { opacity: 0, y: 30 }, "<0.1");
When motion is centralized, debugging and onboarding are easier.
Pattern 5: responsive behavior needs separate logic, not tiny tweaks
Use ScrollTrigger.matchMedia and define desktop/mobile behavior explicitly.
Desktop and mobile should not share pin-heavy assumptions by default.
Typical split:
- desktop: controlled scrub and selective pinning,
- mobile: reduced complexity, mostly reveal-driven motion.
Pattern 6: refresh after known layout shifts
Call ScrollTrigger.refresh() after events that materially change geometry:
- async content hydration/insertion,
- image decode completion,
- accordion open/close,
- font loading impacts,
- viewport mode changes.
If you skip this, trigger boundaries become inaccurate and “random” bugs appear.
Pattern 7: SSR and hydration safety for Next.js
For Next.js/App Router apps:
- keep GSAP code in client components,
- initialize only in effects/hooks,
- avoid server-rendered initial states that conflict with first animation frame.
Animation startup should never cause hydration mismatch or layout jump chaos.
Pattern 8: reduced-motion support is required for quality
Animation is enhancement, not a dependency for comprehension.
Support prefers-reduced-motion with:
- minimal/no-motion fallback,
- immediate visibility states,
- no hidden content requiring animation to become readable.
Accessibility here is both UX and stability win.
Pattern 9: performance budgeting for ScrollTrigger
Set practical constraints up front:
- limit number of active triggers per route,
- avoid heavy per-frame DOM reads/writes,
- batch style changes when possible,
- test on mid-tier devices, not only dev hardware.
Animation that feels smooth on a flagship laptop can still fail in real-world devices.
Pattern 10: debug with markers before changing code blindly
When behavior is wrong, first instrument:
- enable markers,
- inspect start/end points,
- verify trigger element dimensions,
- validate current breakpoint branch.
Guessing wastes time. Instrumentation shortens incident loops.
Recommended architecture in React apps
Use an internal convention:
- animation setup logic in dedicated hook/module per section,
- timeline names and trigger ids documented,
- shared utilities for media-query branching and reduced-motion checks,
- route-level teardown guarantees.
This prevents animation code from becoming ungoverned ad-hoc scripts.
Practical reliability checks
Before you ship, validate:
- No orphaned triggers after route change.
- All selectors are scoped.
- Mobile and desktop strategies validated separately.
prefers-reduced-motionpath works.- Trigger refresh points covered for async layout shifts.
- Marker-based debugging completed and markers removed.
- Mid-tier performance pass completed.
This checklist catches most real production issues.
Common anti-patterns to eliminate
- global selectors across app shells,
- pin/scrub on every section “for polish,”
- missing cleanup in nested route transitions,
- relying on implicit refresh behavior,
- binding animations directly inside random components without ownership boundaries.
These patterns make motion systems fragile and expensive.
Reference implementation: a reusable hook with lifecycle-safe defaults
Use a shared hook so teams stop re-implementing setup/cleanup rules per component.
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger);
export function useFeatureScrollMotion() {
const rootRef = useRef<HTMLDivElement | null>(null);
useGSAP(
() => {
const mm = ScrollTrigger.matchMedia();
mm.add("(min-width: 1024px)", () => {
gsap.from("[data-reveal]", {
y: 20,
opacity: 0,
stagger: 0.08,
scrollTrigger: {
trigger: "[data-section='feature-grid']",
start: "top 75%",
once: true,
},
});
});
mm.add("(max-width: 1023px)", () => {
gsap.from("[data-reveal]", {
y: 12,
opacity: 0,
stagger: 0.04,
scrollTrigger: {
trigger: "[data-section='feature-grid']",
start: "top 85%",
once: true,
},
});
});
return () => mm.revert();
},
{ scope: rootRef }
);
return rootRef;
}
Why this works:
- centralizes ownership and cleanup,
- enforces breakpoint-specific motion strategies,
- keeps selectors scoped to the component boundary.
Reference implementation: refresh after async layout changes
If layout changes after animation setup, refresh deterministically.
React.useEffect(() => {
const onReady = () => ScrollTrigger.refresh();
window.addEventListener("load", onReady);
const timeout = window.setTimeout(onReady, 300);
return () => {
window.removeEventListener("load", onReady);
window.clearTimeout(timeout);
};
}, []);
Use this pattern after image-heavy sections, CMS-injected blocks, or collapsed panels that open post-render.
Closing
ScrollTrigger works extremely well in React when lifecycle ownership is explicit and constraints are intentional.
Scope, cleanup, responsive strategy, refresh discipline, and accessibility support are what separate a flashy demo from a motion system that survives production traffic and ongoing iteration.