CSS Animations vs Framer Motion: When to Use Each
Framer Motion is one of those libraries where the API is so good that you start reaching for it by default. But it adds ~40KB gzipped to your bundle and carries runtime overhead for every animated element. Knowing when CSS is enough — and when it isn't — is what separates thoughtful UI work from over-engineered animation.
Where CSS Wins
CSS handles the majority of UI animations well, and does so with no JavaScript overhead and GPU acceleration by default.
Hover and focus transitions
.button {
background: #090909;
transform: translateY(0);
transition: background 150ms ease, transform 150ms ease, opacity 150ms ease;
}
.button:hover {
background: #1a1a1a;
transform: translateY(-1px);
}
For any interaction that's triggered by a CSS pseudo-class (:hover, :focus, :active), CSS transitions are the right choice. They're faster, simpler, and don't add a component re-render.
Entry animations on static elements
If an element always animates in the same way on page load, CSS keyframes are sufficient:
@keyframes fade-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-text {
animation: fade-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
}
No JS, no layout effect, no bundle cost.
Dark mode transitions
:root { --bg: #f0f0ec; }
.dark { --bg: #090909; }
body {
background: var(--bg);
transition: background 200ms ease;
}
Where Framer Motion Wins
Scroll-driven animations
CSS scroll animations are still not universally supported and have limited control. Framer Motion's useScroll and useTransform are the practical choice:
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const y = useTransform(scrollYProgress, [0, 1], [60, -60]);
const opacity = useTransform(scrollYProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
return (
<motion.div ref={ref} style={{ y, opacity }}>
{children}
</motion.div>
);
Staggered children
Staggering a list of items based on their index is cumbersome in CSS. In Framer Motion:
const container = {
hidden: {},
show: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.2,
},
},
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] } },
};
<motion.ul variants={container} initial="hidden" animate="show">
{items.map((item) => (
<motion.li key={item.id} variants={item}>
{item.label}
</motion.li>
))}
</motion.ul>
Shared layout animations (layout prop)
This is Framer Motion's killer feature — elements that move between positions in the DOM are smoothly interpolated:
<motion.div layoutId="selected-indicator" />
When layoutId is the same across two elements in different positions, Framer Motion automatically animates the transition. CSS can't do this at all.
Gesture-based animations
Drag, pan, and pinch interactions with spring physics:
<motion.div
drag="x"
dragConstraints={{ left: -100, right: 100 }}
onDragEnd={(_, info) => {
if (info.offset.x > 80) onSwipeRight();
if (info.offset.x < -80) onSwipeLeft();
}}
whileDrag={{ scale: 1.02 }}
>
{children}
</motion.div>
Performance Rules for Both
Always animate transform and opacity
Both are composited by the browser and don't trigger layout or paint:
/* ✅ Composited — GPU only */
transform: translateX(10px);
opacity: 0.5;
/* ❌ Triggers layout — expensive */
width: 200px;
margin-left: 10px;
This rule applies whether you're using CSS or Framer Motion.
Use will-change sparingly
/* ✅ For elements you know will animate */
.hero { will-change: transform; }
/* ❌ Don't put this on everything */
* { will-change: transform; }
will-change tells the browser to create a compositing layer. Too many layers waste GPU memory.
Respect prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
In Framer Motion, use the useReducedMotion hook:
const shouldReduce = useReducedMotion();
const animate = shouldReduce ? {} : { y: [0, -10, 0] };
The Decision Heuristic
Use CSS when:
- The trigger is a CSS pseudo-class (hover, focus, active)
- The element always animates in the same way from a static starting point
- You need maximum performance with minimal bundle impact
Use Framer Motion when:
- The animation depends on scroll position, user gestures, or component state
- You need staggered or sequenced animations across children
- You need
layoutIdfor shared element transitions
The best UI code uses both — CSS for the small stuff, Framer Motion for the interactions that CSS can't handle well.