Building in Public#building-in-public

Why I Deleted Framer Motion: Building a Custom Scroll-Reveal Hook Instead

By Mohamed Abdi Guled

Sometimes the "proper" library is the wrong tool for a mobile-first, performance-constrained project.

Why I Deleted Framer Motion: Building a Custom Scroll-Reveal Hook Instead

*Sometimes the "proper" library is the wrong tool for a mobile-first, performance-constrained project.*

Framer Motion is a genuinely good animation library. It's also genuinely heavy, and on ShabelleHub it was doing one job: fading elements in as the user scrolled past them. That's not a job that needs a full animation engine — it's a job for IntersectionObserver and a CSS transition.

The Problem With "Just Use the Library"

It's tempting to reach for a battle-tested package instead of writing your own solution, especially when you're developing without a full IDE and want to minimize the amount of custom code you have to maintain from a phone. But every dependency has a cost, and framer-motion's cost showed up directly in bundle size and, downstream, in PageSpeed scores. For a directory site competing on SEO, that trade-off wasn't worth it for a scroll-reveal effect.

What Replaced It

The replacement was a custom hook — useRevealOnScroll — built on two primitives that ship in every modern browser for free: IntersectionObserver to detect when an element enters the viewport, and CSS transitions to handle the actual fade/slide animation. No JavaScript-driven animation loop, no extra runtime, just a class toggle triggered by an intersection event.

The rough shape of it: the hook attaches an observer to a ref, and when the element crosses the viewport threshold, it adds a class that CSS handles from there. The browser's own compositor handles the transition, which is both cheaper and smoother than a JS-driven equivalent.

Removing It Wasn't Instant

The first attempt at removing framer-motion broke the production deployment. Elements that depended on motion-specific props (rather than plain CSS classes) didn't gracefully degrade — they just stopped animating correctly, and in one case broke the layout entirely. The fix required going back through Vercel's build logs, reading them on a phone screen, and tracing the failure to specific components that hadn't been migrated to the new hook yet. Once every consumer of the old animation system was migrated in one pass instead of piecemeal, the removal went cleanly.

The Bundle Size Number, Concretely

It's easy to say a dependency is "heavy" in the abstract, so it's worth being specific: framer-motion's production bundle contribution was measured before and after removal using the build's own bundle analyzer output, comparing the JavaScript shipped to the client on the homepage specifically, since that's where the scroll-reveal effects were concentrated. The delta wasn't dramatic in isolation — a single removed dependency rarely is — but it was one of several contributing factors across the five-phase performance rebuild, and in a project where every kilobyte was being tracked against a specific PageSpeed target, a measurable, non-trivial reduction in shipped JavaScript for a purely decorative effect was an easy trade to make once it was quantified rather than assumed.

Was It Worth It?

For this specific use case — subtle fade-and-slide-in effects on scroll — yes. The visual result is nearly identical to what framer-motion produced, but the bundle no longer carries an entire animation library for an effect that browsers can already do natively. If ShabelleHub needed complex gesture-based interactions, drag-and-drop, or physics-based animation, framer-motion would earn its place back immediately. For scroll-reveal, it was overkill from the start.

What the Hook Actually Looks Like

Stripped down, the pattern is short enough to hold in your head entirely, which matters when you're reading and reasoning about code on a small screen. A ref gets attached to the element you want to animate. An IntersectionObserver is created once, watching that ref, with a threshold configured for when the element should be considered "visible enough" to trigger — not necessarily fully on-screen, usually a small percentage in. When the callback fires with an intersecting entry, a boolean state flips, and that boolean drives a CSS class on the element. The class itself just defines a transition on opacity and transform, going from an initial hidden-and-offset state to a final visible-and-in-place state. No animation frame loop, no spring physics calculation running on every tick — the browser's compositor thread handles the actual transition once the class is applied, which is part of why it's cheaper than a JavaScript-driven equivalent.

Testing It Actually Matched the Old Behavior

Removing a visual dependency carries a real risk of the replacement looking subtly different — a different easing curve, a different duration, a different trigger point — in ways that are easy to miss on a quick glance but noticeable to anyone comparing before and after closely. Part of the migration process was deliberately comparing the old framer-motion timing values against the new CSS transition values side by side, matching duration and easing as closely as practical, rather than assuming "it fades in, close enough." The goal wasn't just removing weight, it was removing weight without visibly changing what visitors experienced.

The Broader Lesson

Working from a constrained mobile setup forces a kind of discipline that's easy to skip on a powerful desktop: before adding a dependency, ask what it's actually buying you versus what a native browser API could do instead. Sometimes the answer is "a lot" — and sometimes, like here, the answer is "not much, at real cost."

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog