Background pattern
New

Animation with GSAP and React

A practical guide to building high-performance, sequenced animations in React using GSAP — covering gsap.from, stagger, timelines, and ScrollTrigger with live rendered examples.

reactgsapanimationtypescriptnextjs

## What is GSAP?

GSAP (GreenSock Animation Platform) is a battle-tested JavaScript animation library capable of animating anything JavaScript can touch — DOM elements, CSS properties, SVG, canvas, Web Audio, and more. It runs on every browser, respects GPU compositing, and produces silky 60fps motion even on low-end devices.

Unlike CSS keyframes or motion/react, GSAP excels at sequenced, imperative timelines — the kind of choreographed entrance animations where element A finishes, then B starts halfway through C. That control is difficult to express declaratively and is where GSAP shines.

### GSAP vs CSS vs Framer Motion

CSS animationsFramer MotionGSAP
ControlLimitedGoodPrecise
Sequencinganimation-delay hacksvariants staggerTimeline API
Scroll-driven@scroll-timeline (limited support)useScrollScrollTrigger
PerformanceGPU (transform/opacity only)GPUGPU
Bundle size0 kb~50 kb~30 kb (core)
Best forSimple CSS transitionsDeclarative React UIComplex choreography

## Setup

Install GSAP and the official React integration:

npm install gsap @gsap/react

Register the useGSAP plugin once at the module level — this prevents double-registration in strict mode:

app/layout.tsx
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(useGSAP);

## Core concepts

### gsap.to vs gsap.from vs gsap.fromTo

These are the three animation primitives. The difference is simply where the animation starts and ends.

// Animate TO these values from whatever state the element is in
gsap.to('.box', { opacity: 1, y: 0, duration: 0.6 });

// Animate FROM these values to the element's current CSS state
gsap.from('.box', { opacity: 0, y: 40, duration: 0.6 });

// Explicitly define both start and end — most predictable
gsap.fromTo(
  '.box',
  { opacity: 0, y: 40 },   // from
  { opacity: 1, y: 0, duration: 0.6 }  // to
);

Prefer gsap.fromTo in production. gsap.from reads the element's computed style as the end state, which can produce surprising results when styles change after mount (e.g. dark mode, SSR hydration).

### Easing

GSAP ships a comprehensive easing suite. The most useful for UI work:

gsap.from('.box', { y: 40, ease: 'power3.out', duration: 0.5 });   // snappy entrance
gsap.from('.box', { y: 40, ease: 'back.out(1.7)', duration: 0.5 }); // slight overshoot
gsap.from('.box', { y: 40, ease: 'elastic.out(1, 0.4)', duration: 1 }); // springy
gsap.from('.box', { y: 40, ease: 'expo.out', duration: 0.6 });      // very fast then slow

As a rule of thumb: use power2.out or power3.out for most UI entrances. Reserve elastic and back for playful, attention-grabbing moments.


## 1 — Fade In

The simplest GSAP animation: fade an element in from below on mount.

### How it works

  • useGSAP is the React-safe hook from @gsap/react. It handles cleanup automatically on unmount (no useEffect + return () => gsap.killTweensOf(...) boilerplate).
  • The scope option scopes all selector queries to containerRef.current, so .fade-box only matches elements inside that container — safe to reuse across multiple instances.
  • gsap.from starts from opacity: 0, y: 40 and animates to the element's natural state.
  • A replay state counter is passed in dependencies: [replay] — incrementing it kills the previous tween and re-runs the animation. Hit ↺ Replay to see it in action.
components/gsap-fade-in.tsx
'use client';

import { useRef, useState } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';

gsap.registerPlugin(useGSAP);

export default function GsapFadeIn() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [replay, setReplay] = useState(0);

  useGSAP(
    () => {
      gsap.from('.fade-box', {
        opacity: 0,
        y: 40,
        duration: 0.6,
        ease: 'power3.out'
      });
    },
    { scope: containerRef, dependencies: [replay] }
  );

  return (
    <div
      ref={containerRef}
      className="relative flex items-center justify-center p-8"
    >
      <div className="fade-box flex size-32 items-center justify-center rounded-2xl bg-gradient-to-br from-orange-400 to-red-500 text-center text-sm font-bold text-white shadow-lg">
        Fade In
      </div>
      <button
        onClick={() => setReplay((r) => r + 1)}
        title="Replay animation"
        className="absolute top-2 right-2 flex items-center gap-1 rounded-md border border-orange-400/60 bg-neutral-900/80 px-2 py-1 text-xsm font-semibold text-orange-400 backdrop-blur transition-colors hover:border-orange-400 hover:bg-neutral-900 active:scale-95"
      >
        ↺ Replay
      </button>
    </div>
  );
}

Live result — hit ↺ Replay to restart:

Fade In

## 2 — Stagger

Stagger staggers the start time of the same animation across multiple elements — creating a wave or cascade effect.

### How it works

  • stagger: 0.1 means each subsequent .stagger-item starts 100ms after the previous one.
  • Stagger can also be an object: stagger: { each: 0.1, from: 'center' } to radiate outward from the middle.
  • ease: 'back.out(1.7)' adds a small overshoot on arrival — great for tags and chips.
gsap.from('.item', {
  opacity: 0,
  y: 30,
  scale: 0.85,
  stagger: 0.1,         // 100ms between each
  ease: 'back.out(1.7)',
  duration: 0.5
});

// Advanced — radiate from center outward
gsap.from('.item', {
  opacity: 0,
  stagger: { each: 0.08, from: 'center' }
});
components/gsap-stagger.tsx
'use client';

import { useRef, useState } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';

gsap.registerPlugin(useGSAP);

const ITEMS = ['React', 'GSAP', 'Next.js', 'TypeScript', 'Tailwind'];

export default function GsapStagger() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [replay, setReplay] = useState(0);

  useGSAP(
    () => {
      gsap.from('.stagger-item', {
        opacity: 0,
        y: 30,
        scale: 0.85,
        duration: 0.5,
        ease: 'back.out(1.7)',
        stagger: 0.1
      });
    },
    { scope: containerRef, dependencies: [replay] }
  );

  return (
    <div
      ref={containerRef}
      className="relative flex flex-wrap items-center justify-center gap-3 p-8"
    >
      {ITEMS.map((item) => (
        <div
          key={item}
          className="stagger-item rounded-full bg-gradient-to-r from-orange-400 to-red-500 px-5 py-2 text-sm font-semibold text-white shadow"
        >
          {item}
        </div>
      ))}
      <button
        onClick={() => setReplay((r) => r + 1)}
        title="Replay animation"
        className="absolute top-2 right-2 flex items-center gap-1 rounded-md border border-orange-400/60 bg-neutral-900/80 px-2 py-1 text-xsm font-semibold text-orange-400 backdrop-blur transition-colors hover:border-orange-400 hover:bg-neutral-900 active:scale-95"
      >
        ↺ Replay
      </button>
    </div>
  );
}

Live result — hit ↺ Replay to restart:

React
GSAP
Next.js
TypeScript
Tailwind

## 3 — Timeline

A gsap.timeline() lets you chain multiple tweens in sequence, with precise overlap control via position parameters. This is the killer feature that makes GSAP irreplaceable for choreographed UI.

### Position parameter

The second argument to tl.from() / tl.to() is the position parameter — it controls where in the timeline the tween starts:

const tl = gsap.timeline();

tl.from('#avatar', { opacity: 0, scale: 0.5, duration: 0.4 })
  // starts 0.1s before the previous tween ends
  .from('#name',   { opacity: 0, x: -20 },  '-=0.1')
  // starts 0.15s before the previous tween ends
  .from('#role',   { opacity: 0, x: -20 },  '-=0.15')
  // starts at absolute position 0.8s into the timeline
  .from('#badge',  { opacity: 0, y: 10  },  '0.8')
  // starts immediately after a label called "reveal"
  .from('#cta',    { opacity: 0 },           'reveal')
Position syntaxMeaning
'-=0.2'0.2s before the previous tween ends
'+=0.1'0.1s after the previous tween ends
'<'Same start time as the previous tween
'<0.1'0.1s after the previous tween starts
0.5Absolute 0.5s from the timeline start
'label'At the named label

### Profile card entrance

Below is a profile card where the avatar, name, role, and status badge animate in sequence with overlapping timing:

components/gsap-timeline.tsx
'use client';

import { useRef, useState } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';

gsap.registerPlugin(useGSAP);

export default function GsapTimeline() {
  const containerRef = useRef<HTMLDivElement>(null);
  const avatarRef = useRef<HTMLDivElement>(null);
  const nameRef = useRef<HTMLParagraphElement>(null);
  const roleRef = useRef<HTMLParagraphElement>(null);
  const badgeRef = useRef<HTMLDivElement>(null);
  const [replay, setReplay] = useState(0);

  useGSAP(
    () => {
      const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });

      tl.from(avatarRef.current, { opacity: 0, scale: 0.5, duration: 0.45 })
        .from(nameRef.current, { opacity: 0, x: -20, duration: 0.35 }, '-=0.1')
        .from(roleRef.current, { opacity: 0, x: -20, duration: 0.3 }, '-=0.15')
        .from(badgeRef.current, { opacity: 0, y: 10, scale: 0.8, duration: 0.3 }, '-=0.1');
    },
    { scope: containerRef, dependencies: [replay] }
  );

  return (
    <div
      ref={containerRef}
      className="relative flex items-center justify-center p-8"
    >
      <div className="flex items-center gap-4 rounded-2xl bg-neutral-900 px-6 py-4 shadow-xl">
        <div
          ref={avatarRef}
          className="flex size-14 items-center justify-center rounded-full bg-gradient-to-br from-orange-400 to-red-500 text-xl font-bold text-white"
        >
          B
        </div>
        <div className="space-y-0.5">
          <p
            ref={nameRef}
            className="font-bold text-white"
          >
            Braswell Jr.
          </p>
          <p
            ref={roleRef}
            className="text-sm text-neutral-400"
          >
            Software Engineer
          </p>
          <div
            ref={badgeRef}
            className="inline-block rounded-full bg-orange-500/20 px-2 py-0.5 text-xsm font-medium text-orange-400"
          >
            Available
          </div>
        </div>
      </div>
      <button
        onClick={() => setReplay((r) => r + 1)}
        title="Replay animation"
        className="absolute top-2 right-2 flex items-center gap-1 rounded-md border border-orange-400/60 bg-neutral-900/80 px-2 py-1 text-xsm font-semibold text-orange-400 backdrop-blur transition-colors hover:border-orange-400 hover:bg-neutral-900 active:scale-95"
      >
        ↺ Replay
      </button>
    </div>
  );
}

Live result — hit ↺ Replay to restart:

B

Braswell Jr.

Software Engineer

Available

### clearProps: 'all'

Always call clearProps: 'all' on entrance tweens when GSAP animates to a final state that is already expressed in CSS. Without it, GSAP leaves opacity: 1; transform: translateY(0px) as inline styles — which override your CSS and break things like dark mode transitions.

gsap.from('.hero', {
  opacity: 0,
  y: 32,
  duration: 0.5,
  clearProps: 'all'  // removes inline styles when tween completes
});

## 4 — ScrollTrigger

ScrollTrigger is a GSAP plugin that fires animations based on the scroll position. It is far more powerful than CSS @scroll-timeline and works in every browser today.

### Registration

import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

### toggleActions

The toggleActions string controls what happens at four scroll events: onEnter, onLeave, onEnterBack, onLeaveBack.

scrollTrigger: {
  trigger: '.section',
  start: 'top 80%',        // when the top of .section hits 80% of viewport
  end: 'bottom 20%',       // optional end point
  toggleActions: 'play reverse play reverse'
  //              ↑     ↑       ↑     ↑
  //           enter  leave enterB leaveB
}

Common toggleActions patterns:

ValueBehaviour
'play none none none'Play once, never reverse (default)
'play reverse play reverse'Play on enter, reverse on leave, bidirectional
'play pause resume reset'Pause when leaving, resume on re-enter
'restart none none reset'Restart every time you scroll into view

### Cards that animate in on scroll

components/gsap-scroll-trigger.tsx
'use client';

import { useRef, useState } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(useGSAP, ScrollTrigger);

const CARDS = [
  { label: 'Design', color: 'from-violet-500 to-purple-600', icon: '🎨' },
  { label: 'Build', color: 'from-orange-400 to-red-500', icon: '⚡' },
  { label: 'Deploy', color: 'from-emerald-400 to-teal-500', icon: '🚀' }
];

export default function GsapScrollTrigger() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [replay, setReplay] = useState(0);

  useGSAP(
    () => {
      gsap.from('.scroll-card', {
        opacity: 0,
        y: 50,
        scale: 0.9,
        duration: 0.5,
        ease: 'power2.out',
        stagger: 0.12,
        scrollTrigger: {
          trigger: containerRef.current,
          start: 'top 85%',
          toggleActions: 'play reverse play reverse'
        }
      });
    },
    { scope: containerRef, dependencies: [replay] }
  );

  return (
    <div
      ref={containerRef}
      className="relative flex flex-wrap items-center justify-center gap-4 p-8"
    >
      {CARDS.map(({ label, color, icon }) => (
        <div
          key={label}
          className={`scroll-card flex size-28 flex-col items-center justify-center gap-2 rounded-2xl bg-gradient-to-br ${color} text-white shadow-lg`}
        >
          <span className="text-3xl">{icon}</span>
          <span className="text-sm font-bold">{label}</span>
        </div>
      ))}
      <button
        onClick={() => setReplay((r) => r + 1)}
        title="Replay animation"
        className="absolute top-2 right-2 flex items-center gap-1 rounded-md border border-orange-400/60 bg-neutral-900/80 px-2 py-1 text-xsm font-semibold text-orange-400 backdrop-blur transition-colors hover:border-orange-400 hover:bg-neutral-900 active:scale-95"
      >
        ↺ Replay
      </button>
    </div>
  );
}

Live result — scroll into view to trigger, hit ↺ Replay to restart:

🎨Design
Build
🚀Deploy

## 5 — Replaying Animations

Every animation in this post has a ↺ Replay button. The pattern is two lines of code using useGSAP's dependencies array.

### How it works

useGSAP behaves like useEffect — it re-runs whenever its dependencies change. When it re-runs, it automatically kills all tweens and ScrollTriggers created in the previous run before starting fresh. This gives you a clean replay with zero manual cleanup.

components/gsap-replay.tsx
const [replay, setReplay] = useState(0);

useGSAP(
  () => {
    // All tweens here are killed and re-created when `replay` changes
    gsap.from('.element', { opacity: 0, y: 24, duration: 0.5 });
  },
  { scope: containerRef, dependencies: [replay] }
);

// Trigger a replay:
<button onClick={() => setReplay(r => r + 1)}>↺ Replay</button>

### Why dependencies: [replay] instead of tl.restart()

You might think to store a timeline ref and call tl.restart() directly:

// ❌ Fragile — tl.restart() doesn't re-run useGSAP context,
//    so new DOM nodes or changed props won't be picked up
const tlRef = useRef<gsap.core.Timeline>();

useGSAP(() => {
  tlRef.current = gsap.timeline();
  tlRef.current.from('.box', { opacity: 0 });
}, { scope: containerRef });

// This replays the tween but against potentially stale targets
<button onClick={() => tlRef.current?.restart()}>Replay</button>

The dependencies approach is better because:

  • Fresh context — GSAP kills the old context and creates a new one, re-querying all selectors against the current DOM.
  • Works with dynamic content — if the list re-renders with new items, the new elements are picked up.
  • Handles ScrollTriggerstl.restart() doesn't restart nested ScrollTrigger instances; the dependencies approach does.

### Full example

components/gsap-replay.tsx
'use client';

import { useRef, useState } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';

gsap.registerPlugin(useGSAP);

export default function GsapReplay() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [replay, setReplay] = useState(0);

  useGSAP(
    () => {
      const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });

      tl.from('.rp-bar', { scaleX: 0, transformOrigin: 'left', duration: 0.5 })
        .from('.rp-icon', { opacity: 0, scale: 0, duration: 0.4 }, '-=0.2')
        .from('.rp-title', { opacity: 0, x: -16, duration: 0.35 }, '-=0.25')
        .from('.rp-sub', { opacity: 0, x: -12, duration: 0.3 }, '-=0.2')
        .from(
          '.rp-tag',
          {
            opacity: 0,
            y: 8,
            scale: 0.85,
            stagger: 0.08,
            duration: 0.28
          },
          '-=0.15'
        );
    },
    { scope: containerRef, dependencies: [replay] }
  );

  return (
    <div
      ref={containerRef}
      className="relative flex items-center justify-center p-8"
    >
      <div className="w-full max-w-sm overflow-hidden rounded-2xl bg-neutral-900 shadow-xl">
        <div className="rp-bar h-1.5 w-full bg-gradient-to-r from-orange-400 to-red-500" />
        <div className="flex items-start gap-4 p-5">
          <div className="rp-icon flex size-12 shrink-0 items-center justify-center rounded-xl bg-orange-500/20 text-2xl">
            🎬
          </div>
          <div className="min-w-0 space-y-1.5">
            <p className="rp-title font-bold text-white">GSAP Timeline</p>
            <p className="rp-sub text-sm leading-snug text-neutral-400">
              Sequenced entrance with stagger — all driven by one timeline.
            </p>
            <div className="flex flex-wrap gap-2 pt-1">
              {['gsap', 'timeline', 'stagger'].map((t) => (
                <span
                  key={t}
                  className="rp-tag rounded-full bg-orange-500/15 px-2.5 py-0.5 text-xsm font-medium text-orange-400"
                >
                  {t}
                </span>
              ))}
            </div>
          </div>
        </div>
      </div>
      <button
        onClick={() => setReplay((r) => r + 1)}
        title="Replay animation"
        className="absolute top-2 right-2 flex items-center gap-1 rounded-md border border-orange-400/60 bg-neutral-900/80 px-2 py-1 text-xsm font-semibold text-orange-400 backdrop-blur transition-colors hover:border-orange-400 hover:bg-neutral-900 active:scale-95"
      >
        ↺ Replay
      </button>
    </div>
  );
}

Live result:

🎬

GSAP Timeline

Sequenced entrance with stagger — all driven by one timeline.

gsaptimelinestagger

## Putting it all together — the useGSAP hook

useGSAP from @gsap/react is the idiomatic React way to run GSAP. It is a thin wrapper around useLayoutEffect that:

  1. Creates a GSAP context scoped to your container ref.
  2. Automatically kills all tweens and ScrollTriggers created inside it when the component unmounts.
  3. Works correctly with React 18 Strict Mode double-invocation.
components/hero.tsx
'use client';

import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(useGSAP, ScrollTrigger);

export function Hero() {
  const containerRef = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      // All tweens/ScrollTriggers created here are automatically cleaned up
      const tl = gsap.timeline({ defaults: { ease: 'power3.out', clearProps: 'all' } });

      tl.from('.hero-image',   { opacity: 0, scale: 0.9, duration: 0.6 })
        .from('.hero-heading',  { opacity: 0, y: 24,     duration: 0.5 }, '-=0.3')
        .from('.hero-subtext',  { opacity: 0, y: 16,     duration: 0.4 }, '-=0.2')
        .from('.hero-cta',      { opacity: 0, y: 12,     duration: 0.35 }, '-=0.15');

      // ScrollTrigger is also auto-killed on unmount
      gsap.from('.feature-card', {
        opacity: 0,
        y: 40,
        stagger: 0.1,
        ease: 'power2.out',
        duration: 0.5,
        scrollTrigger: {
          trigger: '.features',
          start: 'top 80%',
          toggleActions: 'play reverse play reverse'
        }
      });
    },
    { scope: containerRef } // scopes all class selectors to this element
  );

  return (
    <div ref={containerRef}>
      <section className="hero">
        <img className="hero-image" src="/avatar.png" alt="hero" />
        <h1 className="hero-heading">Build fast. Ship beautiful.</h1>
        <p className="hero-subtext">Animations that feel native, powered by GSAP.</p>
        <button className="hero-cta">Get started</button>
      </section>
      <section className="features">
        <div className="feature-card">Performance</div>
        <div className="feature-card">Flexibility</div>
        <div className="feature-card">Control</div>
      </section>
    </div>
  );
}

## Best practices

Always scope with a container ref. Class-based selectors like .box are global. Without { scope: containerRef }, your animation may accidentally target elements in other mounted components.

Use clearProps: 'all' on entrance tweens. GSAP leaves inline styles after tweens complete. These override your CSS. clearProps: 'all' removes them when the animation finishes.

Register plugins once at module level. Call gsap.registerPlugin(...) outside of components — not inside useGSAP or useEffect. Calling it inside a component re-registers on every render.

Respect prefers-reduced-motion. Read useReducedMotion() from motion/react (or window.matchMedia) and skip or simplify animations for users who have requested reduced motion.

import { useReducedMotion } from 'motion/react';

export function AnimatedCard() {
  const isReduced = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    if (isReduced) return; // skip all animation
    gsap.from(ref.current, { opacity: 0, y: 24, duration: 0.5, clearProps: 'all' });
  }, { scope: ref });

  return <div ref={ref}>Content</div>;
}

Kill ScrollTriggers when content changes. If you re-render a list (e.g. pagination), call ScrollTrigger.refresh() or return a cleanup function from useGSAP that kills specific instances.


## Conclusion

GSAP gives you a level of animation control that CSS and declarative libraries cannot match. The three patterns covered here — fade-in, stagger, timeline, and ScrollTrigger — cover the vast majority of production UI animation needs. Start with gsap.from + useGSAP scoped to a container ref, add a timeline when you need sequencing, and reach for ScrollTrigger when you want animations to respond to scroll position.

Published on June 7, 2026

15 min read

Found an Issue!

Find an issue with this post? Think you could clarify, update or add something? All my posts are available to edit on Github. Any fix, little or small, is appreciated!

Edit on GitHub

Last updated on