Motion vs GSAP for React, and how to actually choose
The decision is smaller than the internet makes it sound. If your animations are tied to React state, Motion will feel like it was designed for the problem you have. If your animations are tied to a scroll position or a long choreographed sequence with many overlapping tweens, GSAP will feel that way instead. Most of the arguments I read online are people generalising from whichever of those two situations they happened to be in.
I build UI components for a living and Aceternity runs on Motion, so state that bias up front. I have also shipped GSAP timelines that Motion would have made painful, and I still reach for GSAP for a specific class of work.
One naming note before anything else. Framer Motion was renamed to Motion. The package is motion and the React entry point is motion/react:
import { motion } from "motion/react";If you see framer-motion in a tutorial, it is the same lineage, just an older name. Motion is MIT licensed and maintained independently of Framer.
The paradigm difference is the whole story
GSAP is imperative. You get a reference to a DOM node and you tell an engine what to do with it over time. The core abstraction is the timeline, which is a container of tweens with positions relative to each other. You can nest timelines, seek to an arbitrary point, reverse, change the time scale mid-flight, and label positions so that later tweens anchor to them.
Motion is declarative. You describe what a component looks like in a given state, and Motion figures out the transition when that state changes. The animate prop is the whole API surface for most work, and variants let a parent broadcast a state name to its children.
That difference explains almost every other difference. Declarative animation fits when the source of truth is React state, because React is already re-rendering when that truth changes. Imperative animation fits when the source of truth is time or scroll offset, because React has no opinion about either and re-rendering sixty times a second to express them is the wrong tool.
Here is a Motion pattern I use constantly, a container that staggers its children in:
"use client";
import { motion } from "motion/react";
const container = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
delayChildren: 0.15,
staggerChildren: 0.08,
},
},
};
const item = {
hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 260, damping: 24 },
},
};
export function FeatureList({ features }: { features: string[] }) {
return (
<motion.ul
variants={container}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.3 }}
className="space-y-3"
>
{features.map((feature) => (
<motion.li
key={feature}
variants={item}
className="rounded-lg border border-neutral-800 p-4 text-neutral-200"
>
{feature}
</motion.li>
))}
</motion.ul>
);
}No refs, no cleanup, no effect. The children inherit visible from the parent because they declare variants and do not override animate. Doing the same in GSAP means a ref on the list plus a query for its children plus a stagger value on the tween. Similar line count, but you now own the lifecycle and the cleanup.
Scroll-driven animation
This is where GSAP is clearly ahead, and for the harder cases it is not close.
ScrollTrigger handles pinning, scrubbing, snapping, and horizontal sections built out of vertical scroll distance. If your design pins a section while panels swap out, and the pin has to release cleanly on resize, ScrollTrigger already solved the awkward parts. Motion has useScroll and useTransform, which cover scroll-linked progress well, but pinning is something you implement yourself with sticky positioning and careful measurement.
The React trap with ScrollTrigger is cleanup. Every instance registers itself globally. If a component unmounts without killing its triggers, those triggers keep referencing detached nodes, and Strict Mode's double-invoked effects hand you duplicates on the first dev run. Symptoms are stale trigger positions, tweens that fire twice, elements stuck mid-animation, and jank that only shows up after a client-side navigation.
gsap.context() exists to fix exactly this. Everything created inside the context function is tracked, and revert() tears it down and restores inline styles:
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
export function ScrollReveal() {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const ctx = gsap.context(() => {
gsap.from(".reveal-card", {
y: 60,
opacity: 0,
duration: 0.8,
ease: "power2.out",
stagger: 0.12,
scrollTrigger: {
trigger: rootRef.current,
start: "top 75%",
end: "bottom 60%",
scrub: 0.5,
},
});
}, rootRef);
return () => ctx.revert();
}, []);
return (
<div ref={rootRef} className="grid gap-6 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<div
key={i}
className="reveal-card h-64 rounded-xl bg-neutral-900 will-change-transform"
/>
))}
</div>
);
}Two details matter here. The second argument to gsap.context() scopes the selector strings, so .reveal-card only matches inside rootRef, which you need the moment this component renders twice on one page. And revert() rather than kill() removes the inline transforms GSAP wrote, so unmounting does not leave a half-animated element behind.
The useGSAP hook from @gsap/react wraps this pattern and handles the dependency array like useEffect. Use it. Hand-rolling the effect is how the cleanup bug gets in.
Exit animations and layout animations
Motion wins these two, and the gap is large enough that it decides projects.
Unmounting is hostile to animation. React removes the node, and there is nothing left to animate. AnimatePresence keeps the node in the tree until its exit transition finishes. You can do this in GSAP, but you are writing the deferred-unmount state machine yourself, per component, and it is the kind of code that rots.
Layout animations are the bigger one. Give two elements the same layoutId and Motion measures both, then animates the transform delta between them. Shared element transitions, tab indicators, expanding cards, all of it falls out of one prop:
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
const projects = [
{ id: "atlas", title: "Atlas", blurb: "Design system audit tooling." },
{ id: "vector", title: "Vector", blurb: "Realtime collaboration server." },
];
export function SharedCardGrid() {
const [openId, setOpenId] = useState<string | null>(null);
const open = projects.find((p) => p.id === openId);
return (
<div className="relative">
<div className="grid gap-4 sm:grid-cols-2">
{projects.map((project) => (
<motion.button
key={project.id}
layoutId={`card-${project.id}`}
onClick={() => setOpenId(project.id)}
className="rounded-xl bg-neutral-900 p-6 text-left"
>
<motion.h3
layoutId={`title-${project.id}`}
className="text-lg font-semibold text-white"
>
{project.title}
</motion.h3>
</motion.button>
))}
</div>
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpenId(null)}
>
<motion.div
layoutId={`card-${open.id}`}
className="w-full max-w-lg rounded-xl bg-neutral-900 p-8"
onClick={(e) => e.stopPropagation()}
>
<motion.h3
layoutId={`title-${open.id}`}
className="text-2xl font-semibold text-white"
>
{open.title}
</motion.h3>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.15 } }}
className="mt-3 text-neutral-400"
>
{open.blurb}
</motion.p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}Writing that in GSAP means measuring both rects with getBoundingClientRect, computing scale and translate, cloning or portalling a node, and reversing it on close. GSAP's Flip plugin does help here. Motion's version is one prop, which is why most of the components I ship lean on it.
Bundle size, tree-shaking, portability
Motion tree-shakes reasonably from motion/react and ships a mini variant for the smallest cases. GSAP's core is compact and plugins are separate entry points, so you pay for ScrollTrigger only if you import it. Neither library is the reason your bundle is large. I have never found an animation library at the top of a bundle report. Fonts, icon sets, chart libraries, and date libraries get there first.
Portability is a genuine difference. GSAP does not care what renders your DOM. If part of your product is a Rails app, or a WebGL canvas, or a Vue admin panel, or an email builder that predates your React rewrite, GSAP travels with you and the team learns one API. Motion is React-first, and while a vanilla motion API exists, the parts people actually want, variants and layout animations, are the React parts.
Licensing and ownership
GSAP is owned by Webflow. In 2025 GSAP became fully free, including plugins that were previously behind the paid Club tier such as SplitText and MorphSVG. Historically the license had restrictions around products that competed with Webflow, which is why the topic still comes up in threads. The direction of change has been in users' favour. That said, a blog post is a bad source of truth for license terms, so read the current license yourself rather than trusting my summary of it.
Motion is MIT and independent, which is a shorter paragraph and part of why I stopped thinking about it.
Using both in one codebase
Plenty of teams run both, which is fine as long as the seam is deliberate. The failure mode is not bundle size. It is two libraries writing to the same element's transform, where GSAP sets an inline transform and Motion's spring overwrites it on the next frame. You get a flicker that only reproduces on slow devices.
The rule I use: one library owns one element. Not one library per page, per element.
Practically, that draws the line like this.
- Motion owns anything driven by React state: modals, dropdowns, route transitions, tab indicators, hover and press feedback, list add and remove.
- GSAP owns anything driven by scroll position or a long timeline: pinned sections, scrubbed sequences, text splitting, SVG morphs, canvas choreography.
Do the handoff at a component boundary. The pinned wrapper is GSAP's, the cards inside it are Motion's, and GSAP never animates the cards directly. If you need GSAP to trigger a Motion animation, have ScrollTrigger call setState and let Motion take it from there. Never let ScrollTrigger touch a node that a motion component renders.
One more caution. If a GSAP timeline animates a container's size while a Motion layout animation measures a child inside it, the two will fight over measurements. Keep layout animations out of GSAP-controlled subtrees.
Which should you pick
| Scenario | Pick | Why |
|---|---|---|
| React app, UI micro-interactions, modals, menus | Motion | State-driven, exit animations, gestures included |
| Marketing site with pinned scroll storytelling | GSAP | ScrollTrigger pinning and scrub are hard to beat |
| Shared element transitions between views | Motion | layoutId does it in one prop |
| Complex multi-stage timelines with overlap and reversal | GSAP | Timelines with labels, seeking, time scaling |
| SVG morphing, text splitting per character or line | GSAP | MorphSVG and SplitText, now free |
| Non-React or mixed-stack product | GSAP | Framework agnostic, one API across the stack |
| Design system or component library in React | Motion | Props compose, no imperative lifecycle to leak |
| Team already fluent in one of them, deadline in two weeks | The one they know | Familiarity beats the marginal fit |
If you are still torn, pick based on what your next three tickets look like, not what your roadmap might contain in a year. Both libraries are stable enough that adding the second one later costs a day.
Performance is mostly about which properties you animate
This part is library-independent, and it matters more than the choice above.
Cheap to animate: transform and opacity. The compositor handles them without recalculating layout or repainting, so they can run off the main thread.
Expensive to animate: width, height, top, left, margin, and anything else that triggers layout. Every frame forces the browser to recompute geometry, and with a deep tree that alone can blow your frame budget. box-shadow and filter do not trigger layout but they repaint large areas, and animating a big blurred shadow at sixty frames per second is one of the most reliable ways to make a Mac fan audible.
Substitutions that work:
- Instead of
widthandheight, animatescaleXandscaleY, or use Motion'slayoutprop, which converts a size change into a transform for you. - Instead of
topandleft, animatexandy, which map totranslate. - Instead of animating
box-shadow, render a second absolutely positioned element that holds the shadow and animate itsopacity. - Instead of animating
filter: blur(), cross-fade between a pre-blurred layer and a sharp one.
will-change: transform helps, but it costs memory, so put it on the handful of elements that actually animate and not on a whole grid. To check your work: record a performance profile, look for purple layout bars during the animation, and if you see them you are animating the wrong property.
Reduced motion
Neither library does this for you by default, and shipping without it is a real accessibility gap.
Motion has useReducedMotion, which reads the media query and re-renders when it changes:
"use client";
import { motion, useReducedMotion } from "motion/react";
export function Reveal({ children }: { children: React.ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.01 : 0.5 }}
>
{children}
</motion.div>
);
}Note that I still fade rather than removing the animation entirely. Reduced motion means less movement, not necessarily zero transition.
GSAP handles it with gsap.matchMedia(), which takes a media query string and runs the matching setup. It reverts that setup for you once the query stops matching.
useEffect(() => {
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
gsap.from(".reveal-card", { y: 60, opacity: 0, stagger: 0.1 });
});
mm.add("(prefers-reduced-motion: reduce)", () => {
gsap.from(".reveal-card", { opacity: 0, duration: 0.2 });
});
return () => mm.revert();
}, []);Where I land
Aceternity is built on Motion because the work is React components whose animations follow state, and because layoutId and AnimatePresence have no clean GSAP equivalent I would want in a library other people paste into their apps. The components and templates built with Motion are the artifact of that choice.
But hand me a scroll-driven brand site with pinned chapters and text that splits per line, and I reach for GSAP without feeling conflicted. Timelines are a better model for choreography than props, and ScrollTrigger has solved edge cases I do not want to rediscover.
The worst outcome is spending a week on this decision. Pick the one that matches the animations you are shipping this month, keep the ownership boundary clean if you end up with both, and spend the saved time auditing which properties you animate. That is where the frame drops actually come from.



