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.
## 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 animations | Framer Motion | GSAP | |
|---|---|---|---|
| Control | Limited | Good | Precise |
| Sequencing | animation-delay hacks | variants stagger | Timeline API |
| Scroll-driven | @scroll-timeline (limited support) | useScroll | ScrollTrigger |
| Performance | GPU (transform/opacity only) | GPU | GPU |
| Bundle size | 0 kb | ~50 kb | ~30 kb (core) |
| Best for | Simple CSS transitions | Declarative React UI | Complex choreography |
## Setup
Install GSAP and the official React integration:
npm install gsap @gsap/reactRegister the useGSAP plugin once at the module level — this prevents double-registration in strict mode:
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 slowAs 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
useGSAPis the React-safe hook from@gsap/react. It handles cleanup automatically on unmount (nouseEffect+return () => gsap.killTweensOf(...)boilerplate).- The
scopeoption scopes all selector queries tocontainerRef.current, so.fade-boxonly matches elements inside that container — safe to reuse across multiple instances. gsap.fromstarts fromopacity: 0, y: 40and animates to the element's natural state.- A
replaystate counter is passed independencies: [replay]— incrementing it kills the previous tween and re-runs the animation. Hit ↺ Replay to see it in action.
'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:
## 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.1means each subsequent.stagger-itemstarts 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' }
});'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:
## 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 syntax | Meaning |
|---|---|
'-=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.5 | Absolute 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:
'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:
Braswell Jr.
Software Engineer
### 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:
| Value | Behaviour |
|---|---|
'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
'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:
## 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.
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 ScrollTriggers —
tl.restart()doesn't restart nestedScrollTriggerinstances; thedependenciesapproach does.
### Full example
'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.
## 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:
- Creates a GSAP context scoped to your container ref.
- Automatically kills all tweens and ScrollTriggers created inside it when the component unmounts.
- Works correctly with React 18 Strict Mode double-invocation.
'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 GitHubLast updated on
