Articles › React
GSAP and ScrollTrigger in React: Professional Scroll Animations Step by Step
Build smooth, professional animations in React with GSAP: tweens and timelines, useGSAP cleanup, ScrollTrigger reveals, scrub and pinning, Lenis smooth scrolling, SplitText and performance tips.
Good motion makes an interface feel alive: content that rises as you scroll, headings that reveal word by word, sections that pin while a story unfolds. GSAP (GreenSock Animation Platform) is the industry-standard library for this — fast, precise and framework-agnostic — and with the @gsap/react package it integrates cleanly with React. This site's animations are built exactly this way.
Install
npm install gsap @gsap/react
Since GSAP 3.13, all plugins — including ScrollTrigger and SplitText — are free.
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger, useGSAP);
Tweens: the building block
A tween animates properties from one state to another:
gsap.to(".card", { y: -20, opacity: 1, duration: 0.8, ease: "expo.out" });
gsap.from(".title", { y: 60, opacity: 0, duration: 1 }); // from these values to current
gsap.fromTo(".bar", { scaleX: 0 }, { scaleX: 1, duration: 1.2 }); // explicit start and end
Useful options: duration, delay, ease, stagger (offset for multiple targets), repeat, yoyo, onComplete.
gsap.from(".skill", { y: 40, opacity: 0, stagger: 0.08, ease: "power3.out" });
Timelines: choreography
A timeline sequences tweens with precise timing:
const tl = gsap.timeline({ defaults: { ease: "expo.out", duration: 1 } });
tl.from(".hero__badge", { y: 24, opacity: 0 })
.from(".hero__title span", { yPercent: 110, stagger: 0.12 }, "-=0.6") // overlap
.from(".hero__actions > *", { y: 20, opacity: 0, stagger: 0.1 }, "<0.2") // relative to previous start
.from(".hero__visual", { scale: 0.85, opacity: 0, duration: 1.6 }, 0.3); // absolute position
Position parameters ("-=0.6", "<", 0.3) let you overlap and align animations without juggling delays. Timelines can be paused, reversed or scrubbed as a unit.
The React way: useGSAP
In React, animations must be cleaned up when components unmount or re-render — otherwise you get duplicated tweens and leaked ScrollTriggers (especially under StrictMode). useGSAP handles this automatically:
function Hero() {
const container = useRef(null);
useGSAP(
() => {
gsap.from(".hero__title", { y: 80, opacity: 0, duration: 1.2, ease: "expo.out" });
},
{ scope: container } // selectors are scoped to this element
);
return (
<section ref={container}>
<h1 className="hero__title">Hello</h1>
</section>
);
}
scopelimits selector text like".hero__title"to the component, so other components aren't affected.- Everything created inside is reverted on unmount.
- For animations triggered by events, wrap handlers with
contextSafe.
If you don't use the hook, gsap.context() inside useLayoutEffect with return () => ctx.revert() does the same job.
ScrollTrigger: animate on scroll
Reveal when an element enters the viewport
useGSAP(() => {
gsap.utils.toArray(".reveal").forEach((el) => {
gsap.from(el, {
y: 50,
opacity: 0,
duration: 1,
ease: "expo.out",
scrollTrigger: {
trigger: el,
start: "top 85%", // when the element's top hits 85% of the viewport height
once: true,
},
});
});
}, { scope: container });
For many elements, ScrollTrigger.batch groups those entering together and staggers them:
ScrollTrigger.batch(".card", {
start: "top 88%",
once: true,
onEnter: (batch) => gsap.from(batch, { y: 40, opacity: 0, stagger: 0.08, ease: "expo.out" }),
});
Scrub: tie progress to the scrollbar
gsap.to(".hero__content", {
yPercent: -10,
opacity: 0.2,
ease: "none",
scrollTrigger: {
trigger: ".hero",
start: "top top",
end: "bottom top",
scrub: true, // or a number (e.g. 0.5) for smoothing
},
});
Pinning
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".story",
start: "top top",
end: "+=2000",
scrub: 1,
pin: true, // keep the section fixed while the timeline plays
},
});
tl.to(".story__step--1", { opacity: 0 })
.from(".story__step--2", { opacity: 0, y: 40 })
.from(".story__step--3", { opacity: 0, y: 40 });
Add markers: true while developing to see start and end positions.
Smooth scrolling with Lenis
Smooth-scroll libraries make scroll-driven animations feel premium. Lenis is lightweight and works with ScrollTrigger:
import Lenis from "lenis";
useEffect(() => {
const lenis = new Lenis({ duration: 1.15, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update); // keep triggers in sync
const tick = (time) => lenis.raf(time * 1000);
gsap.ticker.add(tick); // one shared animation loop
gsap.ticker.lagSmoothing(0);
return () => {
gsap.ticker.remove(tick);
lenis.destroy();
};
}, []);
Remember to stop Lenis (lenis.stop()) while modals are open, and respect prefers-reduced-motion by not enabling it for those users.
Text animations with SplitText
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
const split = SplitText.create(".headline", { type: "words, chars" });
gsap.from(split.chars, { yPercent: 100, opacity: 0, stagger: 0.02, ease: "expo.out" });
Tip: For Persian and Arabic, don't split into characters — it breaks the connected script — and be careful with words: English terms inside RTL text can appear reversed when each word becomes an inline-block. Split RTL text by lines instead.
Refreshing after layout changes
ScrollTrigger measures positions on load. When content changes height later — images load, data arrives, accordions open — call:
ScrollTrigger.refresh();
In SPAs, also refresh after route changes once the new page has rendered.
Performance and accessibility
- Animate
transformandopacity(x, y, scale, rotation, autoAlpha). Avoid animatingwidth,top,leftorbox-shadowon large areas. - Use
will-changesparingly; GSAP handles most optimizations. - Keep durations purposeful (0.4–1.2 s) — motion should guide attention, not delay users.
- Respect reduced motion:
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
// register motion-heavy animations here only
});
- Make sure content is visible without JavaScript or before animations run (avoid hiding critical text with CSS that only JS can undo).
Summary
GSAP gives you precise control with tweens and timelines; useGSAP makes it safe in React with automatic cleanup and scoping; ScrollTrigger connects animation to scrolling with reveals, scrubbing and pinning; Lenis adds smooth scrolling on the same ticker; SplitText animates headlines. Animate transforms and opacity, refresh after layout changes, and respect reduced-motion preferences — and your site will feel polished without sacrificing performance.
FAQ
GSAP or Framer Motion?
Framer Motion (Motion) is great for declarative component and layout animations. GSAP excels at complex timelines, scroll-driven sequences and fine control. Many projects use one; some use both for different needs.
Why do my ScrollTriggers fire at the wrong position?
Usually because the layout changed after they were created (images, fonts, data). Call ScrollTrigger.refresh() after those changes.
Does GSAP work with server-side rendering?
Yes — run animations only on the client (inside effects or useGSAP), never during render.