Concept
CSS transitions and @keyframes handle the large majority of UI motion cheaply and simply. JS animation libraries earn their bundle-size and complexity cost specifically for what CSS genuinely can't do well: orchestrated sequences across many elements with precise timing/staggering, physics-based motion (spring dynamics rather than fixed-duration easing curves), gesture-driven interaction (drag, swipe-to-dismiss with velocity-aware release), scroll-linked animation synced precisely to scroll position, and layout animations (smoothly animating an element from one layout position/size to another, e.g. a shared-element transition between views).
Framer Motion, declarative, React-native animation
import { motion, AnimatePresence } from "framer-motion";
function Card({ isVisible }) {
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
Content
</motion.div>
)}
</AnimatePresence>
);
}Framer Motion's core value for a React codebase: AnimatePresence handles exit animations, animating an element out before it's actually removed from the DOM, which is something CSS alone genuinely cannot do in a component-based framework, since React removes the DOM node immediately on state change with no native way to delay unmounting until an exit animation finishes. This is the single most common reason a React team reaches for Framer Motion over hand-rolled CSS.
// Spring physics, motion driven by mass/stiffness/damping, not a fixed duration + easing curve
<motion.div
animate={{ x: 100 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
/>Spring-based motion feels more natural for interactive, physically-responsive UI (drag-and-release, elements that should feel "alive") than a duration+easing-curve model, since real-world motion doesn't have a fixed duration, it settles based on physical properties. CSS has no native spring physics primitive at all.
// Layout animations, automatically animates position/size changes between renders
<motion.div layout>{content}</motion.div>
// Gesture support
<motion.div drag dragConstraints={{ left: 0, right: 300 }} />layout animation is genuinely hard to replicate in pure CSS, it automatically detects when an element's layout position/size changes between renders (due to a reorder, a sibling being added/removed, a resize) and smoothly animates the transition, using the FLIP technique (First, Last, Invert, Play) under the hood.
GSAP (GreenSock), framework-agnostic, timeline-based, maximal control
import gsap from "gsap";
const tl = gsap.timeline();
tl.to(".card", { opacity: 1, y: 0, duration: 0.5, stagger: 0.1 })
.to(".title", { scale: 1.2, duration: 0.3 }, "-=0.2") // overlaps the previous animation by 0.2s
.to(".button", { backgroundColor: "#4f46e5", duration: 0.2 });GSAP's timeline API is built specifically for orchestrating complex, precisely-sequenced multi-element animations, staggering (stagger: 0.1 animates each matched element with a 0.1s offset from the previous), precise overlap control ("-=0.2" starts 0.2s before the previous tween ends), and labels for referencing specific points in a sequence. It's framework-agnostic (works identically in React, Vue, vanilla JS, or a static site) and has historically had the deepest feature set for complex sequencing and scroll-triggered animation (via the ScrollTrigger plugin) of any JS animation library.
// ScrollTrigger, animation precisely linked to scroll position
gsap.to(".parallax-image", {
scrollTrigger: {
trigger: ".section",
start: "top bottom",
end: "bottom top",
scrub: true, // ties animation progress directly to scroll position, not time
},
y: -100,
});scrub: true is the key capability CSS-only scroll-linked animation (even the newer animation-timeline: scroll(), covered in Modern CSS) has historically had less mature/flexible support for, precisely tying an animation's progress fraction directly to scroll position rather than elapsed time, so scrolling backward reverses the animation exactly, not just replays it forward again.
The real trade-off: bundle size and a second animation system
Every JS animation library adds bundle size (Framer Motion: tens of KB; GSAP core: comparable, more with plugins) and, critically, introduces a second, JS-driven animation system running alongside the browser's native CSS animation engine, meaning JS-based animations don't automatically get the "runs on compositor thread independent of main-thread JS work" benefit that pure CSS transform/opacity transitions get for free. Well-implemented libraries (Framer Motion, GSAP) work hard to still delegate to transform/opacity and use requestAnimationFrame efficiently, so the performance gap versus pure CSS is usually smaller than intuition suggests for a well-used library, but it's not zero, and a page with dozens of independent JS-driven animations competing with other main-thread JS work is a real, measurable difference from equivalent pure-CSS motion.
Deciding: CSS vs. a library
| Need | Tool |
|---|---|
| Hover/focus state change | CSS transition |
| Loading spinner, simple loop | CSS @keyframes |
| Exit animation before React unmount | Framer Motion (AnimatePresence) |
| Drag-and-release with physics | Framer Motion or GSAP |
| Layout-change animation (reorder, resize) | Framer Motion layout |
| Complex multi-element staggered sequence |
Common Mistakes
1. Reaching for a library for something CSS already does well
// Unnecessary, this is a plain CSS transition, no library needed
<motion.div whileHover={{ scale: 1.05 }} transition={{ duration: 0.2 }} />A simple hover scale effect doesn't need Framer Motion at all, transform: scale(1.05); transition: transform 0.2s; in CSS achieves the identical result with zero JS/bundle cost. Reach for a library specifically for what CSS can't do (exit animations, physics, complex orchestration), not as a default replacement for basic hover states.
2. Animating non-compositor properties through a JS library, assuming the library makes it free
// Still triggers layout every frame, regardless of which library is driving it
gsap.to(".box", { width: "300px", duration: 0.5 });The compositor-thread performance rules from the Transitions/Animations topics apply identically whether CSS or a JS library is driving the animation, animating width is still expensive via GSAP, just as it is via a raw CSS transition. Libraries don't bypass the browser's rendering pipeline; they still ultimately set the same CSS properties under the hood.
3. Not cleaning up GSAP timelines/ScrollTriggers on component unmount (in a React app)
useEffect(() => {
const tl = gsap.timeline();
tl.to(".card", { opacity: 1 });
return () => tl.kill(); // required, otherwise the timeline keeps running/referencing removed DOM nodes
}, []);GSAP (and any imperative JS animation library used inside a React component) needs explicit cleanup in a useEffect return function, without it, animations/ScrollTriggers can keep running or holding references after the component unmounts, a real memory leak and source of console errors referencing removed DOM nodes.
4. Overusing layout animations on frequently-updating lists
Framer Motion's automatic layout prop is powerful but not free, applying it broadly across a large, frequently-re-rendering list can introduce real performance overhead, since every layout-affecting re-render now triggers FLIP measurement/animation calculation. Scope it to elements where layout animation is actually visually meaningful, not applied blanket-wide by default.
5. Choosing a library based on popularity rather than the actual need
Framer Motion is the natural default for a React app needing exit animations or gesture support; GSAP is often the better choice for complex, framework-agnostic, precisely-orchestrated sequences or advanced scroll-triggered work. Picking whichever is more familiar/popular without matching it to the actual requirement sometimes means fighting a tool against its grain (e.g. hand-rolling complex timeline orchestration in a library not built for it).
Best Practices
- Default to CSS transitions/animations for the large majority of UI motion, hover states, simple loops, basic entrances that don't need exit animation.
- Reach for Framer Motion specifically for exit animations, gestures, physics, or layout animation in a React codebase.
- Reach for GSAP specifically for complex multi-element orchestration or advanced scroll-linked animation, especially outside React or when framework-agnosticism matters.
- Still animate
transform/opacitywherever possible, even when using a JS library, the compositor performance rules don't change based on what's driving the animation. - Always clean up imperative animations (GSAP timelines, ScrollTriggers) in
useEffectcleanup functions. - Scope
layoutanimations deliberately, not applied blanket-wide across large/frequently-updating lists.
Further Resources
- Framer Motion docs (rebranded from framer-motion to "Motion")
- GSAP docs
- GSAP ScrollTrigger
- web.dev, Animations guide (compositor performance, applies regardless of library)
- Josh W. Comeau, The Physics of CSS/JS Animation and Springs
