Framer Motion (now rebranded as Motion) weighs approximately 34KB minified and gzipped — 32KB if you use the full import. Pure CSS animations run on the compositor thread and add 0KB to your JavaScript bundle. My portfolio (matthewswong.com) uses Framer Motion for page transitions and component animations. I've also built projects where replacing Framer Motion with CSS saved 50KB of bundle and improved the Lighthouse performance score by 8 points. The choice depends on what you're animating and why.
CSS transforms and transitions run on the GPU compositor thread — completely separate from the main JavaScript thread. No JavaScript execution means animations don't compete with event handlers, renders, or other code for CPU time. JavaScript animations (Framer Motion included) run on the main thread via requestAnimationFrame. Framer Motion uses GPU acceleration intelligently through transform: translate3d() and will-change, but the JavaScript orchestration still happens on the main thread. For simple entrance animations (fade in, slide up), CSS is faster. For complex interactive animations (drag, springs, layout animations), Framer Motion's features are worth the trade-off.
The 34KB Framer Motion bundle matters for performance, especially on mobile. The good news: Motion's LazyMotion component reduces the initial bundle to ~6KB by loading features on demand. Use LazyMotion with domAnimation for the most common features (animate, initial, exit, transition) — this covers 90% of use cases at 6KB instead of 34KB. If you're using Framer Motion only for simple fade/slide animations, a 6KB LazyMotion + CSS combination is the pragmatic middle ground.
CSS Animations (compositor thread)
──────────────────────────────────────────
Main Thread [JS execution] [React render] [other work]
│
Compositor Thread ├── transform: translateY(-10px) ← GPU, silky smooth
└── opacity: 0 → 1 ← GPU, no main thread
Framer Motion (main thread orchestration)
──────────────────────────────────────────
Main Thread [JS] [React] [Motion RAF loop] [other work] ← competing
│
Compositor Thread └── final GPU transform ← still GPU
(after JS orchestration)
Bundle size comparison:
CSS animations: 0 KB added to bundle
Framer Motion (full): ~34 KB minified+gzip
Framer Motion (LazyMotion + domAnimation): ~6 KB initial
GSAP (for comparison): ~23 KB
When each wins:
┌────────────────────┬──────────────────┬────────────────────────┐
│ Use CSS for: │ Use Framer for: │ Skip entirely: │
├────────────────────┼──────────────────┼────────────────────────┤
│ Hover effects │ Layout animations│ Animating width/height │
│ Loading spinners │ Exit animations │ (causes reflow) │
│ Skeleton shimmer │ Drag gestures │ Animating box-shadow │
│ Button press │ Spring physics │ (use filter: drop- │
│ Color transitions │ Page transitions │ shadow instead) │
│ Focus rings │ Shared elements │ │
└────────────────────┴──────────────────┴────────────────────────┘From optimizing matthewswong.com's Lighthouse score after adding Framer Motion: wrap all motion components in LazyMotion with domAnimation and domMax only where needed. Also, add will-change: transform to elements that animate frequently — this hints to the browser to promote them to their own compositor layer before the animation starts, eliminating layout thrash. The combination dropped my animation-related layout shift score from 0.12 to 0.02.
CSS wins for: (1) Simple, stateless animations — hover effects, loading spinners, skeleton shimmer, button press feedback. These should always be CSS. (2) Portfolio sites and marketing pages where 34KB of animation library JavaScript costs you real Lighthouse points and real users on slow connections. (3) Animations that don't depend on JavaScript state — if the animation runs the same way regardless of app state, CSS keyframes are the right tool. Tailwind's animate-* classes handle most of these cases without writing any CSS.
// CSS — simple fade-in (prefer this for static animations)
// globals.css
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
// Tailwind arbitrary value (no CSS needed):
<div className="animate-[fadeIn_0.3s_ease-out_forwards]">...</div>
// ──────────────────────────────────────────────────
// Framer Motion — LazyMotion for minimal bundle
import { LazyMotion, domAnimation, m } from "framer-motion"
// Wrap app once (e.g., in layout.tsx)
export default function Layout({ children }) {
return (
<LazyMotion features={domAnimation}>
{children}
</LazyMotion>
)
}
// Use m.div instead of motion.div — works with LazyMotion
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.3 }}
>
Content
</m.div>
// Layout animation — impossible in CSS alone
<m.li layout key={item.id}> {/* animates position change automatically */}
{item.name}
</m.li>
// Exit animations with AnimatePresence
import { AnimatePresence } from "framer-motion"
<AnimatePresence mode="wait">
{isVisible && (
<m.div
key="modal"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
>
<Modal />
</m.div>
)}
</AnimatePresence>Framer Motion wins for: (1) Layout animations — animating between layout states (list reordering, expanding cards) with layout prop. CSS layout animations require complex transforms and are browser-inconsistent. (2) Exit animations — animating components as they unmount. CSS has no clean way to animate unmounting without complex lifecycle management. AnimatePresence handles this elegantly. (3) Physics-based motion — spring animations, drag with momentum, gesture chaining. These are extremely complex in CSS. (4) Shared element transitions between pages — Framer Motion's layoutId handles this; CSS alone cannot.
Framer Motion components are Client Components — they need the browser DOM and React's client runtime. Every component wrapped in <motion.div> (or its equivalent) requires 'use client'. In a Next.js App Router application, wrapping large sections of your page in motion components forces those sections to be Client Components, losing Server Component benefits. Keep Framer Motion at the leaf level — wrap individual interactive elements, not entire page sections. The page layout and data fetching should stay server-side.
My current approach on matthewswong.com: CSS Tailwind animate-* for micro-animations (hover, focus, loading states), Framer Motion with LazyMotion for macro animations (page transitions, content reveals, interactive gestures). The page enters with a Framer Motion fade-in, but every button hover state is Tailwind CSS transition-colors. This keeps the bundle focused — Framer Motion earns its 6KB with features CSS can't match, not with animations that CSS handles natively.
For any project with interactive animations (drag, spring, layout transitions, exit animations), I use Framer Motion with LazyMotion. The DX is excellent — declarative animation state, shared layout animations, and gesture support that would take weeks to implement correctly in vanilla JS. For simpler projects or performance-critical pages, I use CSS only. The 50KB bundle savings from dropping Framer Motion on my performance-sensitive pages were real and measurable. The decision comes down to: do you need JavaScript-powered interactivity in the animation, or just visual polish?
Measure before optimizing. Chrome DevTools Performance panel shows animation frame rate and main thread bottlenecks. The Rendering panel shows paint flashing (red means repainting) and compositing layers. CSS animations that only animate transform and opacity properties will show as compositor-thread only — no red paint flashing. If you see paint flashing on an animation, add transform: translateZ(0) or will-change: transform to promote the element. For Framer Motion, use the chrome-remote-interface timeline to verify your animated elements are GPU-composited.