I run a small web agency in South Florida. We build Next.js sites for local businesses — construction companies, engineering firms, med spas. The kind of companies where every second of load time costs real money.
Last month I was doing a performance audit on a client site we'd built and noticed something ugly: the mobile Lighthouse score was stuck in the low 70s. Desktop was fine — 95+. But mobile was getting murdered by Total Blocking Time.
I opened the bundle analyzer and there it was. Framer Motion was the single largest dependency in the entire project. We were using it for maybe six animations — a few fade-ins on scroll, a mobile nav slide, some hover effects. Six animations. 40KB+ of JavaScript shipped to every visitor.
That's when I decided to evict it entirely.
The Problem With Animation Libraries in 2026
Here's what nobody tells you about Framer Motion (or Motion, as it's now called): it's an incredible library. The API is beautiful. The spring physics are best-in-class. For complex, orchestrated, interactive animations, nothing else comes close.
But for the stuff most business websites actually need — fade-in on scroll, slide transitions, hover states — it's a sledgehammer for a finishing nail.
The issue isn't just the bundle size. It's the execution cost. Framer Motion runs JavaScript on every animation frame. On a flagship phone, that's fine. On the mid-range Android that most of your actual visitors carry, it's a TBT nightmare. Every animation that fires during page load adds to Total Blocking Time, and TBT is the metric that drags your mobile Lighthouse score through the floor.
What I Replaced It With
Two things: CSS transitions and IntersectionObserver. That's it. No new library. No npm install. Just platform APIs.
Fade-in on scroll (before)
import { motion } from 'framer-motion';
function FadeInSection({ children }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
{children}
</motion.div>
);
}
Fade-in on scroll (after)
.fade-in {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
import { useEffect, useRef } from 'react';
function FadeInSection({ children }) {
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
},
{ threshold: 0.1 }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="fade-in">
{children}
</div>
);
}
Same visual result. Zero JavaScript executing during the animation itself — the browser's compositor handles the CSS transition on the GPU. No main thread blocking. No TBT impact.
Mobile nav (before)
<motion.div
initial={{ x: '100%' }}
animate={{ x: isOpen ? 0 : '100%' }}
transition={{ type: 'spring', damping: 25 }}
>
{/* nav content */}
</motion.div>
Mobile nav (after)
.mobile-nav {
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mobile-nav.open {
transform: translateX(0);
}
You lose the spring physics. You gain a mobile nav that doesn't ship 40KB of JavaScript to parse before it can slide open. For a business website, that's a trade I'll make every time.
Hover effects
If you're importing an animation library for hover effects, stop. CSS has had :hover transitions since 2010.
.card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
Done. No React re-renders. No JavaScript. Just CSS doing what CSS was built to do.
The Results
After removing Framer Motion and replacing all animations with CSS transitions + IntersectionObserver:
- Bundle size: down 27%
- Mobile Lighthouse Performance: 72 → 89
- Total Blocking Time: dropped by ~180ms
- Desktop Lighthouse: stayed at 100/100/100/100 (no visual regression)
- Time to Interactive: improved by ~0.4s on mobile
The site looks identical. Every animation still fires. The mobile nav still slides. Cards still lift on hover. Sections still fade in on scroll. But now the browser is doing the animation work on the compositor thread instead of the main thread, and 40KB of JavaScript never gets shipped in the first place.
When You Should Keep Framer Motion
This isn't an anti-Framer-Motion post. Keep it if you need:
-
Layout animations (
layoutprop) — CSS can't animate between DOM position changes - AnimatePresence for exit animations — CSS has no concept of "animate this element as it unmounts"
- Complex orchestrated sequences where multiple elements need coordinated spring physics
- Drag and gesture interactions — this is where Framer truly shines
If you're building a creative portfolio, a SaaS product with complex UI transitions, or anything with drag-and-drop, Framer Motion earns its bundle cost.
But if you're building business websites — landing pages, service pages, marketing sites — and you're importing Framer Motion for fade-ins and hover effects, you're shipping a race car engine to power a bicycle.
The Takeaway
Every npm dependency is a tax your visitors pay on every page load. For business websites where conversion matters, that tax needs to justify itself. When I audited the animations we were actually using, none of them required JavaScript animation. They were all simple transitions that CSS handles natively, on the GPU, with zero main thread cost.
The rule I follow now: CSS first. IntersectionObserver for scroll triggers. Reach for a JS animation library only when CSS literally can't do what you need.
27% smaller bundle. 17-point Lighthouse jump on mobile. Same visual experience. No library needed.
I'm Brad, founder of Sumorai — we build high-performance Next.js websites and AI automation systems for growing businesses in South Florida. If you're dealing with similar performance issues on a client site, reach out.
Top comments (0)