Fuck Framer Motion, I'm going to CSS instead
My portfolio used to run on Framer Motion. Every hover effect, every entrance animation, every subtle transition was powered by a 50kb JavaScript library. It worked fine, but I kept thinking about performance. I kept wondering if CSS could do the same thing faster, cleaner, without the overhead.
Turns out it could. I shipped a complete rewrite that removes Framer Motion entirely and replaces everything with pure CSS animations. The result is faster load times, smoother animations, a smaller bundle, and code that's easier to maintain.
The Case Against Framer Motion
Framer Motion is an excellent library. It makes complex animations straightforward, the API is intuitive, and it handles edge cases well. For a portfolio site with mostly simple effects though, it felt excessive. I was shipping 50kb of JavaScript for animations that CSS handles natively.
Performance was the bigger problem. JavaScript animations run on the main thread, competing with other tasks like rendering and user input. CSS transforms and transitions are GPU-accelerated by default. They run on a separate thread, which means smoother animations and less jank. On lower-end devices, the difference becomes obvious. Animations would occasionally stutter or drop frames, especially during scroll.
Building a Custom Animation System
I created a new system using React hooks and CSS. The core is a set of hooks that handle Intersection Observer logic and return classnames for CSS transitions. Here's the main hook:
export function useEntranceAnimation(delay = 0) {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setTimeout(() => setIsVisible(true), delay)
observer.unobserve(entries[0].target)
}
},
{ threshold: 0.1, rootMargin: '-40% 0px' }
)
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
}, [delay])
return {
ref,
className: isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-5'
}
}The hook uses Intersection Observer to detect when elements enter the viewport. Once visible, it applies CSS classes that trigger transitions. The actual animation happens in CSS using transform and opacity properties, which are cheap to animate and GPU-accelerated.
Converting the Codebase
I converted 25 components across the entire site. The pattern was consistent: replace Framer Motion components with regular HTML elements, swap animation props for CSS classes, and use Intersection Observer hooks instead of Framer Motion's state management.
Hover effects were straightforward. Framer Motion's whileHover became CSS :hover pseudo-classes, whileTap became :active, and scale animations became transform transitions:
// before
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
click me
</motion.button>
// after
<button className="hover:scale-[1.02] active:scale-[0.98] transition-transform duration-200">
click me
</button>Entrance animations required more work. I built specialized hooks for different animation types: useEntranceAnimation for fade-and-slide effects, useStaggerAnimation for sequential reveals, and useScaleAnimation for emphasis. Each hook follows the same pattern but applies different CSS classes.
The trickiest part was replacing AnimatePresence. Framer Motion handles mount and unmount transitions automatically, managing the lifecycle for you. I had to build a custom component that manages visibility state manually and applies CSS transitions during both enter and exit phases.
Measuring the Impact
The bundle size dropped by 50kb. That's 50kb less JavaScript to download, parse, and execute. First Contentful Paint improved, Time to Interactive improved, and the site feels noticeably snappier.
Animations are smoother across the board. CSS transforms run on the GPU and don't block the main thread. On my old MacBook Air, the difference is obvious. No more stuttering during scroll, no more dropped frames on hover, no more layout shifts during page transitions.
I also gained better control over animation timing. All animations now use CSS custom properties for duration and easing. Changing the feel of the entire site is a single variable update:
:root {
--motion-base: 200ms;
--motion-ease-ios: cubic-bezier(0.22, 1, 0.36, 1);
}
.animate {
transition: transform var(--motion-base) var(--motion-ease-ios);
}
What Stayed the Same
I kept the animation philosophy: subtle, purposeful, meaningful. Animations should enhance the experience without distracting from content. I kept the performance profiling system that detects device capabilities and adjusts animation complexity accordingly. Low-end devices get simpler animations or none at all, and users with reduced motion preferences get static content.
Design tokens for spacing, colors, timing, and easing curves all stayed the same. Everything still uses CSS custom properties, and the visual language remained consistent even though the implementation changed completely.
Conclusion
Libraries solve problems, but sometimes you don't have those problems. Framer Motion would be worth the cost for complex animation sequences or gesture-based interactions. For simple entrance effects and hover states though, it's overkill.
CSS is more capable than many developers realize. Modern CSS handles most animation needs with transforms, transitions, keyframes, and Intersection Observer. The platform gives you everything you need for performant animations without external dependencies.
Performance compounds. 50kb might not sound significant, but it adds up. Every dependency is a tradeoff between bundle size, parse time, and execution cost. Sometimes the best solution is the simplest one.