May 11, 2026 · 4 min read
The best performance fix was deleting the smooth scroll
I added a smooth-scroll library because every portfolio seems to have one. Three commits of tuning later, I deleted it in a fourth, and the deletion taught me more than the tuning did.
A portfolio is permission to over-engineer. Nobody's reviewing the PR. So this site launched with a smooth-scroll library, scroll-linked animation on everything, and motion components wrapping half the page tree.
The git log tells the next part better than I can:
73b50e2 fix: switch Lenis to lerp-based scroll for snappy smooth feel
b561cc3 fix: use duration-based easing for smooth scroll on high refresh displays
366bc55 refactor: remove Lenis, native scroll + rewrite tryhard copy
Three commits trying to tune the library. Then the one where I stopped.
Why the smooth scroll had to go
Libraries like Lenis work by taking over scrolling: they intercept your input and drive the page position themselves from a requestAnimationFrame loop, easing toward a target. That's the whole trick, and on a demo reel it looks great.
The bill shows up in everything scroll-adjacent that the browser normally handles for free:
- Anchor links and
scroll-padding-topneed the library's cooperation instead of just working. - Find-in-page, keyboard scrolling, and screen readers now interact with a simulation of scrolling rather than scrolling.
- There's a rAF loop running on the main thread for the entire life of the page, whether anything is animating or not.
- Scroll position becomes something your code owns. Every future feature pays a little tax to that.
Each of those commits in the log above is me paying that tax. One retuned the easing because it felt wrong on high-refresh displays. One chased a horizontal scrollbar through the scroll stack. I was maintaining a worse version of a thing the browser already does, in exchange for easing.
The removal commit touched 8 files: 25 insertions, 112 deletions. No visitor has ever missed the library. I know because there's no way to tell it's gone, which is the point.
- commits spent tuning the scroll
- 3
- lines deleted removing it
- 112
- lines added to replace it
- 25
- motion components that still actually render
- 2
The rule: entry animations are CSS
Deleting Lenis forced a harder question about everything else that moved. The rule I landed on:
If an animation fires once when something appears, it's CSS. If it responds to ongoing user state, it can earn JavaScript.
Most of this site's motion is the first kind. Sections fading up, cards staggering in. That used to be JS-driven motion components shipping to the client. Now the reveal component is a server component that renders no JavaScript at all:
export default function ScrollReveal({ children, direction = "up", delay = 0, variant = "default" }) {
return (
<div
className="reveal-lite"
data-direction={direction}
data-variant={variant}
style={{ animationDelay: delay ? `${delay}s` : undefined }}
>
{children}
</div>
);
}
All the actual work happens in one keyframe, with data attributes selecting the flavor:
.reveal-lite {
animation: reveal-lite 0.55s cubic-bezier(0.25, 1, 0.5, 1) both;
}
@keyframes reveal-lite {
from {
opacity: 0;
transform: translate3d(var(--reveal-x, 0), var(--reveal-y, 18px), 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
Framer Motion is still installed, and an honest accounting of it is a little embarrassing. Ten files import it. Two of them actually render: the mobile menu and a scroll-linked architecture diagram, both of which respond to real user state and earn their JavaScript. The other eight are motion experiments that nothing mounts anymore, sitting in the repo as a record of how much of this I built before asking whether any of it needed to exist.
The smaller cuts
The same pass produced a pile of less glamorous changes that add up:
content-visibility: autoon every section, so off-screen parts of the page skip rendering work until you get near them.- The film grain overlay is a tiled 48px base64 PNG instead of an SVG
feTurbulencefilter. Same texture, none of the filter cost. - The three.js particle background sizes itself to the device: 280 particles at 60 fps on a desktop GPU, stepping down through 180 and 95, to 70 particles at 30 fps on a weak phone. Everyone used to get the desktop scene.
- The mobile menu is a dynamic import, so desktop visitors never download it.
optimizePackageImportsfor the icon and motion libraries, which keeps unused exports out of the bundle.
None of these was the fix on its own. Performance work on a site like this is a posture, asking for every effect what it costs and who pays for it.
The site still has a particle field, a custom cursor, scanlines, and a Konami code. None of that went away. What went away was the cost of running it all on the main thread whether you were looking or not.
Every number in this post is from this site's own git history. The removal is commit 366bc55.