GSAP ScrollTrigger vs Framer Motion: When to Use Each in Next.js
Abhishek Sharma
Software Developer
GSAP ScrollTrigger vs Framer Motion: When to Use Each in Next.js
My portfolio site uses both GSAP ScrollTrigger and Framer Motion in the same Next.js application. This isn't a compromise or an accident -- it's a deliberate architectural choice based on what each library does best. After shipping this approach across three production projects, I want to share the concrete patterns, the performance characteristics, and the rule that prevents them from fighting each other.
The Golden Rule: Never Animate the Same Property with Both
Before anything else, understand this: if GSAP is controlling an element's transform, Framer Motion must not also animate transform on that element. Both libraries write to the DOM on every frame. If they both target the same CSS property on the same element, you get a flickering war where each library overwrites the other's values 60 times per second. The rule is simple -- partition ownership. GSAP owns scroll-driven layout animations (position, scale, opacity of scroll sequences). Framer Motion owns component-level interactions (hover, tap, drag, mount/unmount transitions).
GSAP ScrollTrigger Setup in Next.js
GSAP's biggest challenge in Next.js is SSR. GSAP accesses window and document at import time, which crashes during server-side rendering. The solution is a centralized config module that gates registration behind a browser check:
// lib/gsap-config.ts
"use client"
import { gsap } from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { useGSAP } from "@gsap/react"
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, useGSAP)
}
export { gsap, ScrollTrigger, useGSAP }
Every component that uses GSAP imports from this module, never directly from gsap or gsap/ScrollTrigger. This guarantees the plugin registration check happens exactly once. The "use client" directive ensures this module only runs in the browser, but the typeof window check is still necessary because Next.js may evaluate client modules during SSR for hydration purposes.
The useGSAP hook from @gsap/react is essential. It replaces the old pattern of putting GSAP code in useEffect with manual cleanup. The hook automatically kills all animations and ScrollTriggers created within its scope when the component unmounts, preventing memory leaks that plague GSAP-in-React implementations:
// Without useGSAP (error-prone)
useEffect(() => {
const tl = gsap.timeline({
scrollTrigger: { trigger: ref.current, ... }
});
tl.to(".card", { opacity: 1 });
// Easy to forget cleanup, or clean up incorrectly
return () => {
tl.kill();
ScrollTrigger.getAll().forEach(t => t.kill());
};
}, []);
// With useGSAP (automatic cleanup)
useGSAP(() => {
const tl = gsap.timeline({
scrollTrigger: { trigger: ref.current, ... }
});
tl.to(".card", { opacity: 1 });
// No cleanup needed - useGSAP handles it
}, { scope: sectionRef });
ScrollTrigger.batch for Staggered Reveals
The blog section on my portfolio uses ScrollTrigger.batch to reveal cards with a staggered animation as they enter the viewport. Batch is fundamentally different from putting a ScrollTrigger on each card -- it groups elements that enter the viewport at the same time and animates them together with a stagger, producing a much more polished effect:
// components/sections/blog-section.tsx (actual code)
useGSAP(() => {
const cards = sectionRef.current?.querySelectorAll(".blog-card")
if (!cards || cards.length === 0) return
// Set initial state
gsap.set(cards, { opacity: 0, y: 60, scale: 0.92 })
ScrollTrigger.batch(cards, {
onEnter: (batch) => {
gsap.to(batch, {
scale: 1,
opacity: 1,
y: 0,
duration: 0.7,
stagger: 0.1,
ease: "power2.out",
})
},
start: "top 90%",
once: true,
})
}, { scope: sectionRef })
The once: true parameter is important for performance. Without it, ScrollTrigger recalculates batch membership on every scroll event. With it, the trigger fires once and is disposed. For a one-time reveal animation, there's no reason to keep the trigger alive.
One subtlety: gsap.set() runs synchronously before the browser paints, so users never see a flash of the un-animated state. If you used CSS classes for the initial hidden state instead, you'd risk a FOUC (flash of unstyled content) between hydration and GSAP initialization.
Pin-Based Scroll Sequences with Timeline Control
The story intro section on my portfolio is a 500vh tall section that pins a sticky viewport and scrubs through a 4-phase animation sequence as the user scrolls. This is where GSAP's power really shows -- Framer Motion has no equivalent to pin-scrubbing. Here's the approach using Framer Motion's useScroll and useTransform for the actual implementation (since we're using Framer Motion for this particular section):
// components/sections/story-intro-section.tsx (actual code)
export function StoryIntroSection() {
const containerRef = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"]
})
const sp = useSpring(scrollYProgress, {
stiffness: 100, damping: 30, restDelta: 0.001
})
// Phase 1 (0-0.18): "I build things for the web"
const p1Op = useTransform(
sp, [0, 0.04, 0.14, 0.18], [0, 1, 1, 0]
)
const p1Y = useTransform(sp, [0.02, 0.07], [30, 0])
// Phase 2 (0.18-0.50): Hardware assembly
const cbOp = useTransform(
sp, [0.18, 0.22, 0.26, 0.30], [0, 1, 1, 0]
)
const cbScale = useTransform(
sp, [0.18, 0.22, 0.26, 0.30], [0.7, 1, 1, 0.4]
)
// Phase 3 (0.50-0.70): Browser renders
// Phase 4 (0.70-1.0): "The Craft" finale
return (
<section className="relative bg-background">
<div ref={containerRef} style={{ height: "500vh" }}>
<div className="sticky top-0 h-screen overflow-hidden">
{/* Elements animate based on scroll progress */}
<motion.div style={{ opacity: p1Op, y: p1Y }}>
<h2>I build things for the web.</h2>
</motion.div>
</div>
</div>
</section>
)
}
The pattern here is: a tall outer container creates the scroll distance, a sticky inner container stays pinned to the viewport, and useScroll tracks progress from 0 to 1 through the container. Each phase maps to a slice of that 0-1 range. The useSpring wrapper smooths out jerky scrolling, especially on trackpads.
To achieve the same thing with GSAP's ScrollTrigger pin, the code would look like this:
// GSAP equivalent of the pin-scrub approach
useGSAP(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "+=4000", // 4000px of scroll distance
pin: true, // Pin the container
scrub: 1, // Smooth scrubbing (1s lag)
anticipatePin: 1, // Prevent jump on pin
}
});
// Phase 1: Title appears
tl.fromTo(".phase-1-title",
{ opacity: 0, y: 30 },
{ opacity: 1, y: 0, duration: 0.5 }
)
.to(".phase-1-title",
{ opacity: 0, duration: 0.3 },
"+=0.5" // Hold for 0.5 before fading
);
// Phase 2: Circuit board assembles
tl.fromTo(".circuit-board",
{ opacity: 0, scale: 0.7 },
{ opacity: 1, scale: 1, duration: 0.5 }
)
.fromTo(".laptop-base",
{ opacity: 0, y: 60 },
{ opacity: 1, y: 0, duration: 0.5 },
"-=0.2" // Overlap with previous
);
// ... additional phases
}, { scope: containerRef });
Both approaches work. I chose Framer Motion for the story section because the rest of the component already used Framer Motion for hover effects and micro-interactions. Mixing GSAP ScrollTrigger pin with Framer Motion motion.div elements in the same component would risk violating the golden rule.
Framer Motion Component Animation Patterns
Framer Motion excels at declarative component animations. Its API maps naturally to React's mental model -- animations are props on components, not imperative commands. Here are the patterns I use most:
The whileInView Reveal
// Simple section header reveal
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{
duration: 0.8,
ease: [0.16, 1, 0.3, 1] // Custom cubic bezier
}}
>
<h2>Latest articles.</h2>
</motion.div>
The viewport.margin of "-100px" triggers the animation 100px before the element actually enters the viewport, so it feels like the content appears right as the user reaches it rather than after a jarring delay.
Spring-Based Tilt on Hover
The blog cards use a tilt effect that tracks the mouse position and applies a 3D rotation. This is the kind of interaction GSAP can do, but Framer Motion's spring physics make it feel dramatically better with less code:
// components/sections/blog-section.tsx (actual code)
function TiltWrapper({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
const rotateX = useMotionValue(0)
const rotateY = useMotionValue(0)
const springX = useSpring(rotateX, {
stiffness: 300, damping: 30
})
const springY = useSpring(rotateY, {
stiffness: 300, damping: 30
})
const handleMouseMove = (e: React.MouseEvent) => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width - 0.5
const y = (e.clientY - rect.top) / rect.height - 0.5
rotateX.set(-y * 10)
rotateY.set(x * 10)
}
const handleMouseLeave = () => {
rotateX.set(0)
rotateY.set(0)
}
return (
<motion.div
ref={ref}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{
rotateX: springX,
rotateY: springY,
transformStyle: "preserve-3d",
perspective: 800,
}}
>
{children}
</motion.div>
)
}
The spring configuration (stiffness: 300, damping: 30) creates a responsive but not twitchy feel. Lower stiffness makes the card feel "floaty," higher makes it feel rigid. The spring handles the easing automatically -- no need to define ease curves or durations.
Scroll-Scrubbed Text Reveal
A custom component that reveals text word-by-word as the user scrolls, entirely with Framer Motion:
// components/animations/scroll-reveal-text.tsx (actual code)
export function ScrollRevealText({
children,
className = "",
start = "start 85%",
end = "start 35%",
}: ScrollRevealTextProps) {
const containerRef = useRef<HTMLDivElement>(null)
const words = children.split(" ")
const { scrollYProgress } = useScroll({
target: containerRef,
offset: [start, end],
})
return (
<div ref={containerRef} className={className}>
{words.map((word, i) => {
const rangeStart = i / words.length
const rangeEnd = (i + 1) / words.length
return (
<ScrollWord
key={i}
word={word}
scrollYProgress={scrollYProgress}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
/>
)
})}
</div>
)
}
function ScrollWord({ word, scrollYProgress, rangeStart, rangeEnd }) {
const opacity = useTransform(
scrollYProgress,
[rangeStart, rangeEnd],
[0.15, 1]
)
return (
<motion.span
className="inline-block mr-[0.3em]"
style={{ opacity }}
>
{word}
</motion.span>
)
}
Each word maps to a slice of the scroll progress. As you scroll through the text's position, words brighten from 15% to 100% opacity in sequence. The effect is subtle but creates a reading rhythm that pulls users through the content.
Comparing the Same Animation: Card Reveal
To make the comparison concrete, here's the same card reveal animation implemented in both libraries.
GSAP Version (Batch Reveal)
// GSAP: Cards reveal with stagger when scrolled into view
function GSAPCardGrid({ cards }) {
const containerRef = useRef(null)
useGSAP(() => {
const items = containerRef.current.querySelectorAll(".card")
gsap.set(items, { opacity: 0, y: 60, scale: 0.92 })
ScrollTrigger.batch(items, {
onEnter: (batch) => {
gsap.to(batch, {
opacity: 1, y: 0, scale: 1,
duration: 0.7,
stagger: 0.1,
ease: "power2.out",
})
},
start: "top 85%",
once: true,
})
}, { scope: containerRef })
return (
<div ref={containerRef} className="grid grid-cols-3 gap-6">
{cards.map(card => (
<div key={card.id} className="card">{card.title}</div>
))}
</div>
)
}
Framer Motion Version (Individual Reveal)
// Framer Motion: Cards reveal individually with stagger
function FramerCardGrid({ cards }) {
const containerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.1 }
}
}
const cardVariants = {
hidden: { opacity: 0, y: 60, scale: 0.92 },
visible: {
opacity: 1, y: 0, scale: 1,
transition: {
duration: 0.7,
ease: [0.16, 1, 0.3, 1]
}
}
}
return (
<motion.div
className="grid grid-cols-3 gap-6"
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
>
{cards.map(card => (
<motion.div
key={card.id}
className="card"
variants={cardVariants}
>
{card.title}
</motion.div>
))}
</motion.div>
)
}
The GSAP version has one key advantage: ScrollTrigger.batch intelligently groups elements that enter the viewport at the same time. If you scroll slowly and only two of three cards are visible, it staggers just those two. The Framer Motion version triggers all children at once when the container enters the viewport, regardless of which cards are actually visible. For a 3-card grid this doesn't matter. For a 20-item list, GSAP's batch produces a noticeably better result.
Performance Characteristics
GSAP and Framer Motion animate through fundamentally different mechanisms, and this affects performance in ways that matter.
GSAP runs its own requestAnimationFrame loop, independent of React's rendering cycle. When GSAP animates an element's transform, it writes directly to the DOM. React doesn't know about the change, which is both GSAP's strength (no re-renders) and its weakness (React's virtual DOM is now out of sync with the real DOM). This is why GSAP animations should target elements through refs and class selectors, not through React state.
Framer Motion integrates with React's rendering pipeline. Simple animations use motion values, which bypass React re-renders by writing directly to the DOM (similar to GSAP). But layout animations, AnimatePresence, and variant-based animations trigger React re-renders. For a list of 100 items animating simultaneously, this difference is measurable.
In practice, on a modern device, the performance difference is negligible for most use cases. Where it matters:
- Scroll-linked animations with 20+ elements: GSAP is measurably smoother because it doesn't trigger React reconciliation.
- Complex spring physics on single elements: Framer Motion's motion values are equally performant and the API is cleaner.
- Page transitions with AnimatePresence: Only Framer Motion can do this -- GSAP has no concept of React component lifecycle.
Bundle Size Comparison
This matters for portfolio sites and content-heavy pages where every kilobyte of JavaScript affects Core Web Vitals:
- GSAP core: ~24KB gzipped
- ScrollTrigger plugin: ~11KB gzipped
- @gsap/react (useGSAP): ~1KB gzipped
- GSAP total: ~36KB gzipped
- Framer Motion: ~44KB gzipped (full bundle), but it tree-shakes well. If you only use
motion.div,useScroll, anduseTransform, the actual shipped code is closer to 28-32KB.
Using both libraries, my portfolio ships approximately 65-70KB of animation JavaScript. For a site that treats animation as a core feature, this is acceptable. For a blog or documentation site, it would be excessive -- pick one.
SSR Gotchas with Both Libraries
Both libraries have SSR edge cases that will bite you in Next.js App Router.
GSAP: Any component using GSAP must be a Client Component ("use client"). GSAP cannot render on the server. If you import GSAP in a Server Component, the build fails. The centralized gsap-config.ts pattern solves this, but you need to be vigilant about not importing it in server-side code paths.
Framer Motion: The motion.div component renders valid HTML on the server (it applies the initial styles as inline styles during SSR). This means the server-rendered HTML shows elements in their pre-animation state, which is correct. But AnimatePresence requires client-side JavaScript to work, and useScroll returns 0 during SSR. If your scroll-linked animation's initial state at progress 0 looks wrong (all elements invisible, for example), users see a flash of invisible content before hydration.
The fix is to make sure scroll progress of 0 produces a sensible visual state. In the story intro section, phase 1's opacity starts at 0 (hidden), which means server-rendered HTML shows nothing. This is acceptable because the section is far below the fold. For above-the-fold content, design your useTransform ranges so that progress 0 shows the fully visible state, and the animation plays as the user scrolls away from it.
When to Use Each: Decision Framework
After using both across multiple projects, here is the decision framework that has consistently produced good results:
Use GSAP ScrollTrigger when:
- You need scroll-pinning (sections that stick while content animates through them)
- You're animating 10+ elements with coordinated scroll-linked timing
- You need
ScrollTrigger.batchfor staggered reveals of dynamic lists - You need timeline-based sequencing with precise control over overlaps
- The animation targets are known at mount time (not dynamically added/removed)
Use Framer Motion when:
- You need mount/unmount animations (
AnimatePresence) - You need gesture-driven animations (drag, hover, tap, pan)
- You need spring physics for interactive elements
- The animation is component-scoped (a button's hover state, a card's tilt)
- You need layout animations (smooth reflows when DOM changes)
- You want the animation logic to live in JSX alongside the component
Use both when: your project has scroll-driven narrative sequences AND interactive component animations. Assign clear ownership -- GSAP handles the scroll choreography, Framer Motion handles the component interactions. Never let them touch the same element's transform simultaneously.
The combination works well because they solve genuinely different problems. GSAP is a timeline-based animation engine that happens to work with React. Framer Motion is a React animation library that happens to support scroll. Using each for its strength produces better results than forcing either to do everything.