gsap-scroll-patterns
Complete GSAP and ScrollTrigger patterns for scroll-driven animations. Use when building scroll storytelling, section pinning, parallax effects, or timeline-based choreography.
SKILL.mdskills/gsap-scroll-patterns/SKILL.md
GitHubGSAP & ScrollTrigger Patterns
Purpose
Production-ready GSAP patterns for scroll-driven animation. Every example is complete and copy-pasteable. After reading this skill, you can build a full scroll storytelling page from these patterns alone.
When to Use This Skill
Use when:
- Scroll-driven animations (section reveals, parallax, pinning)
- Timeline choreography (sequenced multi-element animations)
- Text splitting animations (character/word reveals)
- Any animation that must sync to scroll position
Do NOT use when:
- High performance animations are not required, or for basic component-level triggers that can be handled via Framer Motion.
CRITICAL: Never use GSAP and Framer Motion in the same component tree. If a section uses GSAP, every animation in that section must be GSAP.
Phase 1: GSAP Animation Strategy
1. Core API
gsap.to() — Animate TO target values
// Move element 100px right and fade in over 1 second
gsap.to(".element", {
x: 100,
opacity: 1,
duration: 1,
ease: "power2.out",
});
gsap.from() — Animate FROM starting values (element snaps to its CSS position at end)
// Element starts 50px below and invisible, animates to its natural position
gsap.from(".element", {
y: 50,
opacity: 0,
duration: 0.8,
ease: "power3.out",
});
gsap.fromTo() — Explicit start AND end values
// Full control: define both start and end states
gsap.fromTo(".element",
{ y: 30, opacity: 0, scale: 0.95 }, // FROM
{ y: 0, opacity: 1, scale: 1, duration: 1 } // TO
);
Property Shorthands
x, y → translateX, translateY (GPU-accelerated)
scale → scale transform
rotation → rotate in degrees
scaleX, scaleY → directional scale
xPercent → translateX in percent of element width
yPercent → translateY in percent of element height
RULE: Only animate transform and opacity. Never animate width, height, top, left, margin, or padding. These trigger layout recalculation and kill 60fps.
Easing Reference
"none" → Linear (robotic, avoid for UI)
"power1.out" → Subtle deceleration (buttons, micro-interactions)
"power2.out" → Standard deceleration (most UI animations)
"power3.out" → Pronounced deceleration (entrance reveals)
"power4.out" → Dramatic deceleration (hero elements)
"back.out(1.7)" → Slight overshoot (playful, bouncy)
"elastic.out(1, 0.3)" → Elastic spring (celebrations, success states)
"expo.out" → Extreme deceleration (premium, Apple-style)
// For scroll-scrubbed animations, easing is irrelevant — scroll position
// directly controls progress. Use "none" or omit ease entirely.
2. Timelines
Timelines chain animations in sequence. Without a timeline, animations fire simultaneously.
const tl = gsap.timeline();
tl.from(".hero-title", { y: 40, opacity: 0, duration: 0.8, ease: "power3.out" })
.from(".hero-subtitle", { y: 30, opacity: 0, duration: 0.6, ease: "power3.out" }, "-=0.4")
.from(".hero-cta", { y: 20, opacity: 0, duration: 0.5, ease: "power2.out" }, "-=0.3");
// Position parameter (3rd argument):
// "-=0.4" → start 0.4s BEFORE previous animation ends (overlap)
// "+=0.2" → start 0.2s AFTER previous animation ends (gap)
// "<" → start at same time as previous animation
// "<0.2" → start 0.2s after previous animation STARTS
Timeline Defaults
// Set shared defaults to reduce repetition
const tl = gsap.timeline({
defaults: { duration: 0.8, ease: "power3.out" },
});
tl.from(".line-1", { y: 40, opacity: 0 })
.from(".line-2", { y: 40, opacity: 0 }, "-=0.5")
.from(".line-3", { y: 40, opacity: 0 }, "-=0.5");
3. ScrollTrigger Fundamentals
Installation
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
Basic Trigger
gsap.from(".feature-card", {
y: 60,
opacity: 0,
duration: 1,
ease: "power3.out",
scrollTrigger: {
trigger: ".feature-card", // Element that triggers the animation
start: "top 80%", // "trigger-position viewport-position"
end: "top 20%", // When to end (for scrub)
toggleActions: "play none none none",
// toggleActions: "onEnter onLeave onEnterBack onLeaveBack"
// Values: "play", "pause", "resume", "reset", "restart", "complete", "reverse", "none"
},
});
Start/End Syntax
"top 80%" → when the TOP of trigger hits 80% from viewport top
"top center" → when the TOP of trigger hits the viewport center
"top top" → when the TOP of trigger hits the viewport top
"bottom top" → when the BOTTOM of trigger reaches the viewport top
"center center" → when the CENTER of trigger is at viewport center
// Pixel offsets:
"top 80%-=100" → 100px above the 80% mark
Scrub — Link Animation to Scroll Position
gsap.to(".progress-bar", {
scaleX: 1,
ease: "none", // Linear for scroll-linked — easing feels wrong with scrub
scrollTrigger: {
trigger: ".content-section",
start: "top top",
end: "bottom bottom",
scrub: true, // Direct 1:1 scroll-to-progress mapping
// scrub: 0.5, // 0.5 second smoothing lag (feels polished)
// scrub: 1, // 1 second lag (smooth, cinematic)
// scrub: 3, // 3 second lag (lazy, dramatic)
},
});
Pin — Lock Element During Scroll
ScrollTrigger.create({
trigger: ".pinned-section",
start: "top top",
end: "+=1000", // Pin for 1000px of scroll distance
pin: true, // Lock the trigger element in place
pinSpacing: true, // Push content below down (default, usually correct)
anticipatePin: 1, // iOS Safari: pre-calculate pin position to prevent jump
});
Debug Markers
scrollTrigger: {
markers: true, // DEVELOPMENT ONLY — shows start/end markers on screen
}
// ALWAYS remove markers before shipping.
4. Battle-Tested Patterns
Pattern 1: Section Reveal on Scroll
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger);
export function SectionReveal({ children }: { children: React.ReactNode }) {
const sectionRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
const elements = sectionRef.current?.querySelectorAll("[data-animate]");
if (!elements) return;
elements.forEach((el, i) => {
gsap.from(el, {
y: 40,
opacity: 0,
duration: 0.8,
delay: i * 0.1,
ease: "power3.out",
scrollTrigger: {
trigger: el,
start: "top 85%",
toggleActions: "play none none none",
},
});
});
}, { scope: sectionRef });
return <div ref={sectionRef}>{children}</div>;
}
// Usage:
// <SectionReveal>
// <h2 data-animate>Title</h2>
// <p data-animate>Description</p>
// <button data-animate>CTA</button>
// </SectionReveal>
Pattern 2: Pinned Section with Timeline
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger);
export function PinnedFeatureShowcase() {
const containerRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "+=3000", // 3000px of scroll to complete the timeline
pin: true,
scrub: 1,
anticipatePin: 1,
},
});
tl.from(".feature-1", { opacity: 0, y: 30 })
.to(".feature-1", { opacity: 0, y: -30 }, "+=0.3")
.from(".feature-2", { opacity: 0, y: 30 })
.to(".feature-2", { opacity: 0, y: -30 }, "+=0.3")
.from(".feature-3", { opacity: 0, y: 30 });
}, { scope: containerRef });
return (
<section ref={containerRef} className="relative h-screen overflow-hidden">
<div className="feature-1 absolute inset-0 flex items-center justify-center">
<h2>Feature One</h2>
</div>
<div className="feature-2 absolute inset-0 flex items-center justify-center">
<h2>Feature Two</h2>
</div>
<div className="feature-3 absolute inset-0 flex items-center justify-center">
<h2>Feature Three</h2>
</div>
</section>
);
}
Pattern 3: Horizontal Scroll Section
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger);
export function HorizontalScroll() {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
const track = trackRef.current;
if (!track) return;
const totalWidth = track.scrollWidth - window.innerWidth;
gsap.to(track, {
x: -totalWidth,
ease: "none",
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: () => `+=${totalWidth}`,
pin: true,
scrub: 0.5,
anticipatePin: 1,
invalidateOnRefresh: true, // Recalculate on resize
},
});
}, { scope: containerRef });
return (
<section ref={containerRef} className="overflow-hidden">
<div ref={trackRef} className="flex gap-8 w-max px-8">
{/* Horizontal scroll panels */}
<div className="w-screen h-screen flex-shrink-0">Panel 1</div>
<div className="w-screen h-screen flex-shrink-0">Panel 2</div>
<div className="w-screen h-screen flex-shrink-0">Panel 3</div>
</div>
</section>
);
}
Pattern 4: Parallax Layer Stack
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger);
export function ParallaxHero() {
const containerRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
// Foreground moves fast, background moves slow
gsap.to(".parallax-bg", {
yPercent: -20,
ease: "none",
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "bottom top",
scrub: true,
},
});
gsap.to(".parallax-mid", {
yPercent: -40,
ease: "none",
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "bottom top",
scrub: true,
},
});
gsap.to(".parallax-fg", {
yPercent: -60,
ease: "none",
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "bottom top",
scrub: true,
},
});
}, { scope: containerRef });
return (
<section ref={containerRef} className="relative h-[150vh] overflow-hidden">
<div className="parallax-bg absolute inset-0">Background Layer</div>
<div className="parallax-mid absolute inset-0">Midground Layer</div>
<div className="parallax-fg absolute inset-0">Foreground Layer</div>
</section>
);
}
Pattern 5: Batch Stagger for Repeated Elements
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(ScrollTrigger);
export function StaggeredCards() {
const containerRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
ScrollTrigger.batch(".stagger-card", {
onEnter: (elements) => {
gsap.from(elements, {
y: 40,
opacity: 0,
duration: 0.8,
ease: "power3.out",
stagger: 0.1,
});
},
start: "top 85%",
once: true, // Only animate once, don't reverse
});
}, { scope: containerRef });
return (
<div ref={containerRef} className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="stagger-card">Card 1</div>
<div className="stagger-card">Card 2</div>
<div className="stagger-card">Card 3</div>
<div className="stagger-card">Card 4</div>
<div className="stagger-card">Card 5</div>
<div className="stagger-card">Card 6</div>
</div>
);
}
5. Next.js Integration
The useGSAP Hook (Required for All GSAP in React)
// Install: npm install @gsap/react
import { useGSAP } from "@gsap/react";
// useGSAP replaces useEffect + useLayoutEffect for GSAP.
// It automatically creates a gsap.context() and cleans up on unmount.
export function AnimatedComponent() {
const containerRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
// All GSAP code goes inside this callback.
// Selectors are scoped to containerRef automatically.
gsap.from(".child-element", { y: 30, opacity: 0 });
}, { scope: containerRef }); // Scope limits selectors to this container
// DEPENDENCY ARRAY (optional, 2nd arg after config):
// useGSAP(() => { ... }, { scope: ref, dependencies: [someState] });
// Re-runs when dependencies change, cleaning up previous animations.
return <div ref={containerRef}>...</div>;
}
RULE: Every GSAP animation in a React component MUST use useGSAP. Never use raw useEffect with GSAP — cleanup is unreliable.
ScrollTrigger.refresh() After Dynamic Content
useGSAP(() => {
// After images load or dynamic content renders, recalculate positions
ScrollTrigger.refresh();
}, { dependencies: [dataLoaded] });
// Also refresh on window resize (automatic, but manual trigger if layout shifts):
// ScrollTrigger.addEventListener("refreshInit", () => { ... });
6. Responsive Scroll Animations
useGSAP(() => {
const mm = gsap.matchMedia();
mm.add("(min-width: 768px)", () => {
// Desktop-only: horizontal scroll
gsap.to(".track", {
x: -totalWidth,
scrollTrigger: { trigger: ".container", pin: true, scrub: 1 },
});
});
mm.add("(max-width: 767px)", () => {
// Mobile: vertical stack, simple reveals
gsap.from(".track > *", {
y: 30,
opacity: 0,
stagger: 0.1,
scrollTrigger: { trigger: ".container", start: "top 80%" },
});
});
// Reduced motion — CRITICAL for accessibility
mm.add("(prefers-reduced-motion: reduce)", () => {
// Kill all scroll animations. Show content immediately.
ScrollTrigger.getAll().forEach(st => st.kill());
gsap.set("[data-animate]", { opacity: 1, y: 0, x: 0 });
});
}, { scope: containerRef });
7. iOS Safari Compatibility
| Issue | Fix |
|---|---|
| Pin jumps on scroll start | anticipatePin: 1 on every pinned ScrollTrigger |
| Address bar resize causes layout shift | Use min-h-[100dvh] instead of h-screen |
| Overscroll bounce interferes with pin | overscroll-behavior: none on <body> |
| Momentum scroll overshoots end position | Use scrub: 0.5 minimum (never scrub: true) |
| Pinned section flickers | Add will-change: transform to pinned element |
| Content below pin overlaps | Ensure pinSpacing: true (default, don't disable) |
8. Text Splitting Animation
// Requires: npm install gsap @gsap/react
// SplitText is a GSAP Club plugin. For free alternative, split manually:
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
export function TextReveal({ text }: { text: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
const chars = containerRef.current?.querySelectorAll(".char");
if (!chars) return;
gsap.from(chars, {
y: "100%",
opacity: 0,
duration: 0.6,
ease: "power3.out",
stagger: 0.02,
scrollTrigger: {
trigger: containerRef.current,
start: "top 80%",
toggleActions: "play none none none",
},
});
}, { scope: containerRef });
return (
<div ref={containerRef} className="overflow-hidden">
<span className="inline-flex flex-wrap">
{text.split("").map((char, i) => (
<span key={i} className="char inline-block" style={{ whiteSpace: char === " " ? "pre" : undefined }}>
{char}
</span>
))}
</span>
</div>
);
}
9. Anti-Patterns
| ❌ Don't | ✅ Do | Why |
|---|---|---|
gsap.to(el, { width: "100%" }) |
gsap.to(el, { scaleX: 1 }) |
width triggers layout. scaleX is GPU-only. |
gsap.to(el, { height: 0 }) |
gsap.to(el, { scaleY: 0 }) |
Same — avoid layout-triggering properties. |
useEffect(() => { gsap.to(...) }) |
useGSAP(() => { gsap.to(...) }) |
useGSAP handles cleanup. useEffect causes memory leaks. |
| Pinning inside a flex container | Pin a wrapper around the flex container | Flex recalculates during pin, causing jitter. |
scrub: true on short timelines |
scrub: 0.5 minimum |
true = direct mapping, feels jerky on short timelines. |
Forgetting anticipatePin |
Always set anticipatePin: 1 |
iOS Safari jump on pin start. |
Leaving markers: true in production |
Remove or gate behind process.env.NODE_ENV |
Visible debug markers in production. |
| Mixing GSAP + Framer Motion in one tree | Use one library per component tree boundary | Conflicting transform management causes unpredictable behavior. |
10. Verification Checklist
- Every GSAP call is inside
useGSAP()with ascoperef - Every ScrollTrigger has explicit
startandendvalues - Only
transformandopacityare animated (no layout properties) -
anticipatePin: 1is set on every pinned ScrollTrigger -
scrubuses a numeric value (not baretrue) on timelines under 2000px -
markers: trueis removed before production -
prefers-reduced-motionis handled viagsap.matchMedia() -
ScrollTrigger.refresh()is called after dynamic content loads - Mobile breakpoint has simplified or disabled scroll animations
- No GSAP and Framer Motion in the same component tree
Output Format / Delivery
- High-fidelity scroll storytelling and timeline animations using GSAP.
- Responsive, accessible scroll-pinned structures with clean GSAP cleanup.
Behavior Rules
- Always use useGSAP hook for proper garbage collection.
- Always check prefers-reduced-motion via gsap.matchMedia().
Maintenance Notes
- Updated to match structural guidelines in June 2026.