Framer Motion vs. CSS Transitions
The choice between Framer Motion and CSS Transitions in React has more to do with the animation type than with personal preference. Hover effects and simple state transitions belong in CSS. Orchestrated sequences, exit animations and shared element transitions are barely achievable without Framer Motion. This tutorial explains when which solution is the right one.
Table of Contents
- 1. Why React Animations are so often approached the wrong way
- 2. CSS Transitions and Animations in React, the foundation
- 3. Framer Motion: the most important concepts
- 4. Enter and exit animations: where CSS falls short
- 5. Animation orchestration with Framer Motion
- 6. Performance: what CSS animations and JS animations cost
- 7. Bundle size and load times: Framer Motion in context
- 8. View Transitions API: the browser-native approach
- 9. Framer Motion vs. CSS Transitions: direct comparison
- 10. Summary
- 11. FAQ
1. Why React Animations are so often approached the wrong way
React Animations are frequently implemented with the wrong tool in practice. Teams reach for Framer Motion for simple hover effects that could be solved with a single line of CSS, creating JavaScript overhead for a task the browser handles natively and more efficiently. Or the reverse: they try to build complex fade-out animations that should play while an element is being removed from the DOM using pure CSS Transitions, which structurally does not work, because the element is removed from the DOM immediately on unmount, before the transition can finish.
The core problem with React Animations: React's component model is not built around animation by default. An unmounted element disappears instantly, there is no native hook for "animate before removal". CSS Transitions only work for state changes, not for mount/unmount. Framer Motion solves exactly this gap with AnimatePresence. The right choice between CSS and Framer Motion therefore primarily depends on which animation type you need, not on what is currently trendy or what other projects use.
2. CSS Transitions and Animations in React, the foundation
CSS Transitions are the most efficient way to build state-dependent React Animations. They run on the browser's compositor thread, with zero JavaScript involvement, meaning no React render, no JS computation overhead. For hover effects, state-based color changes, size changes and simple fade-ins driven by class toggles, CSS Transitions are the first choice. With Tailwind CSS, many of these React Animations can be expressed directly in JSX through utility classes like transition-all duration-300 ease-in-out and state-dependent classes (className={open ? 'opacity-100' : 'opacity-0'}).
CSS @keyframes animations are suited to animation sequences that run independently of state, loading indicators, pulse effects, continuous rotations. They also run on the compositor thread, as long as only transform and opacity are animated. Animating layout properties like width, height, margin and padding, on the other hand, triggers reflow and layout calculations, which is something to avoid both for CSS Transitions and for Framer Motion. The golden rule for performant React Animations: only animate transform (translate, scale, rotate) and opacity.
// CSS Transitions in React, no JS animation library needed for simple cases
import { useState } from 'react';
function NotificationBadge({ count }: { count: number }) {
const visible = count > 0;
return (
// CSS transition handles the opacity/scale change, runs on compositor thread
<span
className={[
'inline-flex items-center justify-center rounded-full text-xs font-bold',
'bg-red-500 text-white px-1.5 py-0.5',
'transition-all duration-200 ease-out',
// State-driven classes, React just toggles them, browser handles animation
visible ? 'opacity-100 scale-100' : 'opacity-0 scale-75',
].join(' ')}
>
{count}
</span>
);
}
// Keyframe animation for a loading spinner, pure CSS, zero JS overhead
function LoadingSpinner() {
return (
<div
className="w-6 h-6 rounded-full border-2 border-slate-200 border-t-sky-600"
style={{ animation: 'spin 0.7s linear infinite' }}
/>
// @keyframes spin defined in global CSS:
// @keyframes spin { to { transform: rotate(360deg); } }
);
}
// Height transition: max-height trick for collapse, avoid animating height directly
function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(o => !o)}>{title}</button>
{/* Animate max-height instead of height, enables CSS transitions for collapse */}
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: open ? '500px' : '0px' }}
>
<div className="py-4">{children}</div>
</div>
</div>
);
}
3. Framer Motion: the most important concepts
Framer Motion is built on three core concepts: motion components, variants and AnimatePresence. A motion.div component is a normal div that understands three additional props: initial (starting state), animate (target state) and exit (state on unmount). Framer Motion interpolates between these states with its own animation engine, which supports physics-based spring animations, staggered sequences and complex timing curves. For this class of React Animations, no CSS-only approach offers comparable expressive power.
variants are named animation states that propagate between parent and child components. When a parent component switches from "hidden" to "visible", all children can react to the same variant change, with staggered delays (staggerChildren) without explicitly passing delay props. This enables list animations where each item fades in after the previous one, with minimal code. React Animations of this complexity cannot be expressed with CSS alone, Framer Motion is the pragmatic choice here.
4. Enter and exit animations: where CSS falls short
The fundamental problem with exit React Animations and CSS: when React removes an element from the DOM, that happens synchronously and immediately. A CSS Transition meant to play on removal never gets a chance, the element is gone before the transition can start. This is not a React bug, it is a structural property of the DOM model. Framer Motion's AnimatePresence solves this problem: it delays removal from the DOM until the exit animation has finished.
AnimatePresence wraps the conditionally rendered part of the UI and watches which children get removed. When a child unmounts, AnimatePresence runs the exit animation and only removes the element from the DOM afterwards. This enables elegant React Animations for modals, toasts, dropdown menus, routing transitions and every other pattern where elements fade in and out. With the mode="wait" option, AnimatePresence waits until the outgoing element has disappeared before the incoming one appears, ideal for page transitions.
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
// Modal with enter and exit animation, impossible with CSS alone
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
// AnimatePresence keeps the element mounted until exit animation completes
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop fade */}
<motion.div
key="backdrop"
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Modal slide-up with spring physics */}
<motion.div
key="modal"
className="fixed inset-x-4 bottom-0 sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 bg-white rounded-2xl p-6 shadow-2xl z-50"
initial={{ opacity: 0, y: 60, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 60, scale: 0.95 }}
// Spring physics for natural feel, not possible with CSS ease curves
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
);
}
// Staggered list animation using variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08, // each child starts 80ms after the previous
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
function AnimatedList({ items }: { items: string[] }) {
return (
<motion.ul variants={containerVariants} initial="hidden" animate="visible">
{items.map((item, i) => (
// Children inherit variant names from parent, no delay props needed
<motion.li key={i} variants={itemVariants}>
{item}
</motion.li>
))}
</motion.ul>
);
}
5. Animation orchestration with Framer Motion
Orchestrated React Animations, where multiple elements animate in a coordinated sequence, are Framer Motion's home turf. The key concept is the propagation of variants through the component tree: a parent component controls the animation state, all children react to it automatically, and staggerChildren or delayChildren in the transition options coordinate the timing. The result: staggered fade-in animations for lists, cards and navigation menus with minimal code.
For elaborate page transitions, Framer Motion's LayoutGroup and layoutId offer the "shared layout" feature: an element can be animated between different positions in the component tree as long as both versions carry the same layoutId. React recognizes that this is the same element, and Framer Motion automatically animates the FLIP transition (First-Last-Invert-Play). This pattern is very elegant for React Animations such as product card transitions to detail pages or tab transitions with a moving underline, and it is barely achievable without an external library.
6. Performance: what CSS animations and JS animations cost
The critical difference in the performance of React Animations: CSS Transitions on transform and opacity run on the compositor thread, entirely outside the JavaScript main thread and the React render cycle. They never block page interactivity, even when the JS thread is currently busy. Framer Motion also runs on transform and opacity by default and uses the Web Animations API or requestAnimationFrame for it, which is performant, but still JavaScript-driven and therefore dependent on the main thread.
With useMotionValue and useTransform, Framer Motion offers the ability to animate values directly without triggering a React re-render. This is performance-critical for complex React Animations that react to scroll position or mouse movement: the animation values change many times per second, but React does not re-render the component, only the DOM property is updated directly by Framer Motion. This pattern is the right choice for scroll-driven animation effects and avoids thousands of unnecessary re-renders per second.
7. Bundle size and load times: Framer Motion in context
Framer Motion has a non-trivial bundle size: minified and gzipped roughly 45 to 55 KB. That is a real cost factor for React Animations in performance-critical applications. Anyone using Framer Motion for a single hover effect is carrying that overhead disproportionately. The decision rule: if the project needs several complex animation patterns, modals with exit animations, staggered lists, page transitions, the bundle size pays for itself against the development effort you would otherwise sink into CSS hacks.
Since version 10, Framer Motion has offered a motion export from framer-motion/client and supports tree-shaking. With explicit imports (import { motion } from 'framer-motion' versus named imports for specific features), the amount of included code can be reduced. Alternatively for smaller React Animations requirements: @motionone/dom (~15 KB) or the Web Animations API directly. For projects that do not need complex animation orchestration, react-spring (~20 KB) is a lighter alternative with spring physics support.
8. View Transitions API: the browser-native approach
The View Transitions API is a browser-native alternative for certain React Animations: page transitions and element transitions without a JavaScript animation library. It works by wrapping DOM changes in document.startViewTransition(() => { /* DOM change */ }). The browser automatically takes a screenshot of the old state, performs the DOM change, and animates the transition between old and new with CSS. Since Chrome 111 and Safari 18, the API is broadly available.
In a React context, the View Transitions API is especially relevant for routing transitions. React Router 7 and Next.js have built in experimental support for View Transitions. The pattern: on a navigation event, the DOM change (router update) is wrapped in startViewTransition, and the browser animates the page change with a standard cross-fade or a CSS-adjusted transition. For simple page transitions, this is the leanest approach with zero bundle overhead. For more complex React Animations with interactivity dependencies, Framer Motion remains the more complete solution.
import { motion, useMotionValue, useTransform, useScroll } from 'framer-motion';
// Scroll-driven animation, no re-render on scroll, only DOM updates
function ParallaxHero({ imageSrc }: { imageSrc: string }) {
const { scrollY } = useScroll();
// Transform scroll position to translateY, direct DOM update, no React re-render
const y = useTransform(scrollY, [0, 500], [0, -150]);
return (
<div className="relative h-screen overflow-hidden">
<motion.img
src={imageSrc}
alt="Hero"
className="absolute inset-0 w-full h-full object-cover"
style={{ y }} // motion value, bypasses React state, goes directly to style
/>
</div>
);
}
// View Transitions API, browser-native, no Framer Motion needed for simple page transitions
function NavigationLink({ href, children }: { href: string; children: React.ReactNode }) {
const navigate = useNavigate(); // React Router
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
// Wrap navigation in View Transition for smooth cross-fade
if (!document.startViewTransition) {
navigate(href);
return;
}
document.startViewTransition(() => {
// React Router updates the DOM inside the transition callback
navigate(href);
});
};
return <a href={href} onClick={handleClick}>{children}</a>;
}
// Gesture-driven animation with drag constraints
function DraggableCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
drag="x"
dragConstraints={{ left: -100, right: 100 }}
dragElastic={0.2}
// Spring physics snap back to center when released
whileDrag={{ scale: 1.05, cursor: 'grabbing' }}
className="cursor-grab bg-white rounded-2xl p-6 shadow-lg"
>
{children}
</motion.div>
);
}
9. Framer Motion vs. CSS Transitions: direct comparison
Which technology for which React Animation? The following overview gives clear decision rules based on the animation type.
| Animation Type | Recommendation | Reason | Performance |
|---|---|---|---|
| Hover effects | CSS Transition | No JS overhead, compositor thread | Optimal |
| State transition (class) | CSS Transition | Tailwind classes are enough | Optimal |
| Loading indicators | CSS @keyframes | Continuous, no state | Optimal |
| Modal fade-in | Framer Motion | Exit animation on unmount | Good (transform/opacity) |
| Staggered lists | Framer Motion | staggerChildren coordination | Good |
| Page transitions | View Transitions API | Browser-native, no bundle | Optimal (native) |
| Scroll animations | Framer Motion | useMotionValue without re-render | Good (no re-render) |
| Drag & gesture | Framer Motion | drag prop, spring physics | Good |
The table shows: almost half of typical React Animations requirements can be solved without Framer Motion. CSS Transitions and @keyframes are sufficient for everything that is state-driven and does not involve a mount/unmount transition. Framer Motion pays off when exit animations, spring physics, orchestration or scroll parallax are needed. The View Transitions API is the wildcard: browser-native, zero bundle size, ideal for routing transitions, but still with limited browser support for complex scenarios.
Mironsoft
React animations, UI performance and frontend architecture
React animations for your product?
We implement performant React Animations with CSS Transitions and Framer Motion, matched to the animation type, the performance requirements and the bundle size goals of the project.
Animation audit
Analyzing existing animations for performance bottlenecks and unnecessary library dependencies
Framer Motion
Implementing complex exit animations, orchestrated sequences and shared layout transitions
Performance
Identifying layout thrashing, introducing transform/opacity-only animations, optimizing the bundle
10. Summary
React Animations need the right tool for the right animation type. CSS Transitions on transform and opacity are the most performant solution for state-dependent changes: compositor thread, no JavaScript, no bundle overhead. CSS @keyframes for continuous animations without state dependency. Framer Motion for the animation classes CSS structurally cannot do: exit animations on unmount with AnimatePresence, orchestrated staggered sequences with variants, spring physics for a natural feel of motion, and scroll-driven effects without re-renders using useMotionValue.
The View Transitions API is the emerging third path for React Animations in a routing context, browser-native, without bundle overhead and with growing framework integration. The most important rule of thumb remains: never animate layout properties (width, height, margin), only ever transform and opacity. Anyone who respects this boundary achieves 60fps animations equally well with CSS Transitions and Framer Motion, the difference lies purely in expressive power and features, not in baseline performance.
React Animations, the essentials at a glance
CSS Transitions
For hover, state changes and simple fade-in/fade-out. Compositor thread, no JS overhead. Animate transform and opacity, never layout properties.
Framer Motion
For exit animations (AnimatePresence), orchestrated sequences (variants + staggerChildren), spring physics and scroll-driven effects (useMotionValue).
View Transitions API
Browser-native for page transitions. document.startViewTransition() wrapping DOM changes. No bundle overhead. Check browser support.
Performance rule
Only animate transform (translate, scale, rotate) and opacity, whether CSS or Framer Motion. Layout properties trigger reflow and should be avoided.