By following this guide, you will build a fully functional scroll-triggered animation system in a React application using GSAP 3.12 and ScrollTrigger. The whole setup takes around 45 to 60 minutes and produces production-ready, GPU-accelerated animations that do not cause layout thrash.

What You'll Build

  • A React component that animates elements into view as the user scrolls down the page
  • A reusable custom hook that attaches and cleans up GSAP ScrollTrigger instances automatically
  • A scrubbed timeline animation that ties animation progress directly to scroll position
  • A configuration that avoids common performance pitfalls like paint storms and janky frame drops

Prerequisites

  • Node 20 or later and pnpm 9 (or npm 10)
  • A React 18 or 19 project (Vite, Next.js 15, or Create React App all work)
  • Basic familiarity with React hooks and CSS transforms
  • A GSAP Club account if you want Club plugins, though ScrollTrigger ships free with the core GSAP npm package

Step 1: Install GSAP and Register the Plugin

Why does this step matter?

GSAP splits its plugin system at runtime. If you forget to call gsap.registerPlugin(), ScrollTrigger silently does nothing. That silent failure costs developers hours of debugging.

Install the package:

pnpm add gsap

Then register ScrollTrigger once at the application entry point. In a Next.js 15 app, do this inside a client component. In Vite, put it in main.tsx.

// src/main.tsx (Vite) or src/app/providers.tsx (Next.js)
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

Common pitfall: In Next.js with the App Router, never import GSAP in a Server Component. Add 'use client' at the top of any file that touches GSAP.

Step 2: Create a Reusable useScrollAnimation Hook

Why a custom hook instead of inline useEffect?

Inline effects scatter cleanup logic. A dedicated hook encapsulates the GSAP context and kills all animations when the component unmounts. This prevents memory leaks that accumulate across route changes.

// src/hooks/useScrollAnimation.ts
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

type ScrollAnimationConfig = {
  from: gsap.TweenVars;
  to: gsap.TweenVars;
  trigger?: string | Element | null;
  start?: string;
  end?: string;
  scrub?: boolean | number;
  markers?: boolean;
};

export function useScrollAnimation(
  config: ScrollAnimationConfig
) {
  const elementRef = useRef(null);

  useEffect(() => {
    const el = elementRef.current;
    if (!el) return;

    const ctx = gsap.context(() => {
      gsap.fromTo(el, config.from, {
        ...config.to,
        scrollTrigger: {
          trigger: config.trigger ?? el,
          start: config.start ?? 'top 80%',
          end: config.end ?? 'bottom 20%',
          scrub: config.scrub ?? false,
          markers: config.markers ?? false,
        },
      });
    });

    return () => ctx.revert();
  }, []);

  return elementRef;
}

Pro tip: gsap.context(), introduced in GSAP 3.11, scopes all tweens to a parent element. Calling ctx.revert() kills every tween created inside that context. This is the correct cleanup pattern for React as of 2026.

Step 3: Build a Fade-Up Reveal Component

This is the most common scroll animation pattern on the web. Elements start invisible and below their natural position, then animate into place when they enter the viewport.

// src/components/RevealOnScroll.tsx
'use client';

import React from 'react';
import { useScrollAnimation } from '@/hooks/useScrollAnimation';

type Props = {
  children: React.ReactNode;
  className?: string;
  delay?: number;
};

export function RevealOnScroll({ children, className, delay = 0 }: Props) {
  const ref = useScrollAnimation({
    from: { opacity: 0, y: 40 },
    to: {
      opacity: 1,
      y: 0,
      duration: 0.8,
      ease: 'power3.out',
      delay,
    },
    start: 'top 85%',
  });

  return (
    
{children}
); }

Use it anywhere in your page:

import { RevealOnScroll } from '@/components/RevealOnScroll';

export default function HeroSection() {
  return (
    

Your headline here

Supporting copy that follows 150ms later.

); }

Common pitfall: Stagger delays above 0.3 seconds feel slow on mobile. Keep delays between 0.1 and 0.2 seconds for sequential elements.

Step 4: Build a Scrubbed Parallax Timeline

What is scrub and when should you use it?

Scrub ties animation progress to scroll position rather than running the animation once on entry. Setting scrub: 1 adds a 1-second lag between scroll position and animation state, which produces a smooth, organic feel.

This example moves a background image at half the scroll speed, creating depth without a full parallax library.

// src/components/ParallaxBanner.tsx
'use client';

import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

export function ParallaxBanner({ src }: { src: string }) {
  const wrapperRef = useRef(null);
  const imageRef = useRef(null);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    const image = imageRef.current;
    if (!wrapper || !image) return;

    const ctx = gsap.context(() => {
      gsap.fromTo(
        image,
        { yPercent: -15 },
        {
          yPercent: 15,
          ease: 'none',
          scrollTrigger: {
            trigger: wrapper,
            start: 'top bottom',
            end: 'bottom top',
            scrub: 1,
          },
        }
      );
    });

    return () => ctx.revert();
  }, []);

  return (
    
); }

What if the parallax jumps on first load?

Call ScrollTrigger.refresh() after images load. Add this to your root layout or page component:

window.addEventListener('load', () => ScrollTrigger.refresh());

Step 5: Stagger a List of Cards

Staggered reveals are common on landing pages, pricing sections, and feature grids. GSAP handles this natively with gsap.utils.toArray().

// src/components/CardGrid.tsx
'use client';

import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

type Card = { id: string; title: string };

export function CardGrid({ cards }: { cards: Card[] }) {
  const gridRef = useRef(null);

  useEffect(() => {
    const grid = gridRef.current;
    if (!grid) return;

    const ctx = gsap.context(() => {
      const items = gsap.utils.toArray('.card-item', grid);

      gsap.fromTo(
        items,
        { opacity: 0, y: 50, scale: 0.95 },
        {
          opacity: 1,
          y: 0,
          scale: 1,
          duration: 0.6,
          ease: 'power2.out',
          stagger: 0.1,
          scrollTrigger: {
            trigger: grid,
            start: 'top 75%',
          },
        }
      );
    });

    return () => ctx.revert();
  }, []);

  return (
    
{cards.map((card) => (

{card.title}

))}
); }

Pro tip: A stagger of 0.1 seconds works well for grids of 3 to 6 items. For longer lists, reduce it to 0.05 seconds so the last card does not take too long to appear.

Step 6: Add Reduced Motion Support

Why does this step matter for accessibility?

The prefers-reduced-motion media query exists in all modern browsers as of 2026. Users who have vestibular disorders or motion sensitivity enable this setting. Ignoring it is an accessibility failure, and it can violate WCAG 2.2 Success Criterion 2.3.3.

Wrap your GSAP defaults at the registration point:

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (prefersReducedMotion) {
  gsap.globalTimeline.timeScale(100);
  ScrollTrigger.config({ limitCallbacks: true });
}

Setting timeScale(100) completes all animations near-instantly. Users still see the final state, but without the motion that causes discomfort.

Step 7: Test Performance With DevTools

What should the Performance panel show?

Open Chrome DevTools, record a scroll session, and check the Layers panel. Every animated element should show a composited layer. Composited layers run on the GPU and do not trigger layout or paint.

GSAP only animates transform and opacity by default. Both are compositor-safe properties. Avoid animating top, left, width, or height inside ScrollTrigger callbacks. Those properties trigger layout recalculation and will cause frame drops on lower-end devices like entry-level Android phones.

A well-configured scroll animation system should maintain 60fps on devices from 2022 or later, and 90fps or 120fps on high-refresh-rate displays when the animation complexity is low.

If you are building a product at this level of detail and want an outside review, the team at Lenka Studio regularly audits front-end animation systems as part of broader UI development engagements.

Frequently Asked Questions

Does GSAP ScrollTrigger work with Next.js App Router?

Yes, but only inside Client Components. Add 'use client' to any file that imports GSAP. Server Components cannot access the browser's scroll events or the DOM.

Why are my ScrollTrigger animations firing at the wrong position?

This usually means ScrollTrigger calculated element positions before the page finished laying out. Call ScrollTrigger.refresh() after fonts, images, and dynamic content have loaded. In Next.js, put this inside a useEffect with an empty dependency array.

How is GSAP ScrollTrigger different from the Intersection Observer API?

Intersection Observer tells you when an element enters or leaves the viewport. ScrollTrigger does that plus scrubbing, pinning, snapping, and direct progress callbacks tied to exact scroll position. For simple fade-ins, Intersection Observer is lighter. For anything scrubbed or sequenced, ScrollTrigger is faster to implement and more reliable across browsers.

Will this work with React Server Components or streaming SSR?

GSAP requires the DOM, so it only runs client-side. The components in this guide include 'use client' directives. Server-rendered HTML will appear in its initial state (opacity 0 or transformed) until hydration completes. Use visibility: hidden in CSS as a fallback to prevent content flashing before JavaScript loads.

Is GSAP free to use commercially?

GSAP's core library and ScrollTrigger are free for commercial use under the standard GSAP license as of 2026. Club GSAP plugins like SplitText and MorphSVG require a paid membership. Check the official GSAP licensing page at gsap.com for the current terms before using it in a client project.

Next Steps

You now have a reusable hook, a reveal component, a parallax banner, a staggered card grid, and reduced motion support. The logical next step is combining these into a scroll-driven page narrative, where section reveals, scrubbed backgrounds, and card staggers work together as a single system.

From there, explore GSAP's Timeline API for sequencing multiple animations into one coordinated flow. If your project uses design tokens generated from Figma, you can drive animation durations and easing values directly from those tokens to keep motion consistent across your design system.

If you are building a product or marketing site and want scroll animations that feel considered rather than decorative, Lenka Studio works with SMBs across Australia, Singapore, Canada, and the US to build interfaces at this level of craft. Reach out and describe what you are building.