GSAP ScrollTrigger in React: The Patterns That Actually Work

GSAP ScrollTrigger in React: The Patterns That Actually Work
Brandon Perfetti

Technical PM + Software Engineer

Topics:FrontendWeb DevelopmentDeveloper Experience
Tech:ReactJavaScript

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:

  1. where setup runs,
  2. where cleanup runs,
  3. what DOM scope it controls,
  4. when responsive variants switch,
  5. 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:

  1. No orphaned triggers after route change.
  2. All selectors are scoped.
  3. Mobile and desktop strategies validated separately.
  4. prefers-reduced-motion path works.
  5. Trigger refresh points covered for async layout shifts.
  6. Marker-based debugging completed and markers removed.
  7. 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.