Removing the animation library cost 45 KB less than keeping it
This site's first load was 333 KB gzipped. The HTML was not the problem — 308 KB raw compressing to 54 KB, because inlineCss duplicates a lot of very compressible CSS. The problem was entirely JavaScript, on a site that is mostly static text.
The obvious suspect was motion. The non-obvious part was whether removing it was worth the rewrite, and that question is where most performance work goes wrong: you guess, you spend a weekend, and then you measure.
First, the per-library numbers were lying
The initial estimate said Lenis cost 19.6 KB. It cost about 5 KB. The figure came from fingerprinting each library against the built chunks, which attributes an entire shared chunk to every library found inside it — and that chunk was mostly Radix.
The rewrite was all-or-nothing
The plan had been to start with RevealOnScroll, convert it to CSS, and measure the win before continuing. That would have saved exactly zero bytes. motion was imported by eight components, not the two or three assumed:
RevealOnScroll,ScrollProgress,Navbar,BottomNavHero,ExperienceTimeline,ProfileImage,MagneticButton
A single remaining import pulls in the whole library. Partial conversion buys nothing, so the decision was not "which component first" but "all eight, or none" — a much larger commitment to make on a guess.
Measuring a removal without doing the removal
Instead of rewriting eight components to find out, alias the library to an empty stub, let the build fail its type check on purpose, and read the number:
{
"compilerOptions": {
"paths": {
"motion/react": ["./src/stub-motion.ts"]
}
}
}typescript: { ignoreBuildErrors: true },Build, measure, revert. The spike predicted 286 KB. The finished rewrite measured 287 KB — within a kilobyte. Five minutes of work replaced a multi-hour guess, and it is worth doing before any large dependency removal.
What replaced it
The interesting one is scroll-linked motion. A hook writes a 0–1 progress value into a CSS custom property from a passive requestAnimationFrame listener, and never calls setState — re-rendering React on every scroll frame is precisely the cost that made a physics library look necessary in the first place.
It takes an explicit resting value for prefers-reduced-motion, because the correct one differs per use: a parallax must settle at 0, a progress rail at 1. Getting that backwards would have left the hero invisible for reduced-motion users — which is the kind of bug that does not show up in a bundle graph.
MagneticButton writes transform straight to the node rather than through state, so there is no render per mousemove, and a CSS transition does the easing. The navbar's sliding pill measures the active <li> and moves one shared indicator, which is what layoutId was doing.
The part I got wrong
Lenis went out in the same pass and came back the same day. Scrolling immediately stopped feeling right, and that was a correct complaint: CSS scroll-behavior: smooth only eases anchor jumps and programmatic scrolls. It does nothing for the wheel, so nothing was replacing the inertial feel.
It was also only ever worth about 5 KB — Motion was the entire prize. Lenis is back, but behind await import("lenis") inside an effect, so the 5.3 KB sits in its own chunk outside first load.
Result
333 KB → 288 KB gzipped, a 45 KB reduction of 14%, with the smooth scrolling intact and prefers-reduced-motion still honored. Every entry animation on the site is now CSS keyframes plus a utility class, and there is no animation library in the critical path.