Every page on the site stopped responding to wheel and trackpad input as soon as a visitor clicked a single link. Nobody reported it for two and a half months.
Symptom
Load the home page, click a project card, try to scroll the case study: nothing moves. The scrollbar still works. So do the space bar, the arrow keys, and Page Down. Only the wheel and the trackpad are dead, and they stay dead on every page visited afterwards until a full reload.
That split is the whole diagnosis. Keyboard scrolling and scrollbar
dragging go through the browser's native path. Wheel and touch go through
Lenis, which binds a non-passive listener and calls preventDefault() on
the gesture before running its own animation. A dead wheel with a live
keyboard means something intercepted the gesture and then declined to act
on it.
Root cause
Lenis caches the scrollable height rather than reading it per event, and
keeps that cache fresh with a ResizeObserver on document.documentElement.
app/layout.tsx set h-full on <html> — height: 100% — which pins
that element's box to the viewport permanently. Its height never changes
when page content grows, so after the initial measurement the observer
never fires again. On macOS, with overlay scrollbars, the width never
changes either, so there is no accidental self-heal.
The cached scrollHeight therefore froze at whatever the first page
measured. Lenis computes limit.y = scrollHeight - height, and the home
page is deliberately short — one viewport plus a little. onVirtualScroll
still called preventDefault() on every wheel event, then clamped its
scrollTo to that stale limit. The gesture was consumed and discarded.
Measured against production before the fix, at a 757px viewport:
<html> computed height | wheel travel | |
|---|---|---|
/ | 757px | — |
/projects/serlei (5476px of content) | 757px | 186px |
186px is not arbitrary. The home page's own overflow is 945 − 757 = 188px. That number was being carried, intact, onto a page nearly six times taller.
Lenis ships a stylesheet whose entire content exists to prevent this:
html.lenis,
html.lenis body {
height: auto;
}
lenis/dist/lenis.css was never imported. Nothing in the repo referenced
it, and nothing failed loudly when it was absent.
Two commits set this up, two days apart and neither wrong on its own.
f52efb9 (2026-05-26) scaffolded the project with h-full on <html> —
a conventional full-height shell. 7313c86 (2026-05-28) added the Lenis
provider, which silently requires that exact declaration to be absent. The
defect is the interaction, and it shipped with the second commit.
Why it wasn't caught
Every automated check in this repository loads a route directly. The
Playwright captures navigate with goto. The Lighthouse runs target a
single URL. A direct load re-runs the Lenis constructor against the page
you are actually on, so the cached height is correct and scrolling works
perfectly. The one interaction that triggers the bug — moving from one
page to another without a document load — was never exercised by anything.
The site also has almost no internal links. From the home page a visitor
can reach four case studies and nothing else; /status, /availability,
/repl, /changelog and /resume had no inbound link from anywhere.
Manual passes reached those pages by typing the URL, which is a document
load, which hides the bug.
This is the second time this file has been the subject of a scroll fix.
f57b67b (2026-06-05) corrected a genuinely different failure in the same
provider — a stopped-but-still-mounted Lenis instance locking the page
under prefers-reduced-motion — and the post-mortem at
/incidents/hydration-mismatch already
records that commit as having been aimed at the wrong bug once before. It
was aimed at the wrong bug twice. The reduced-motion branch it introduced
is also why the bug never appeared under reduced motion: that path skips
Lenis entirely and hands scrolling back to the browser.
Fix
Four changes, three of them independent, so no single one is load-bearing:
- Import
lenis/dist/lenis.css, which unpins<html>whenever Lenis is active. - Drop
h-fullfrom<html>andmin-h-fullfrom<body>so nothing competes for that declaration in the first place. - Recompute explicitly on route change.
usePathname()drives alenis.resize()two animation frames after the App Router commits — the first frame lands before the browser has laid out the new subtree, the second guarantees it has. This covers the case the stylesheet cannot: two pages of identical height, where the observer correctly never fires and the limit is nonetheless wrong. min-h-dvhinstead ofmin-h-screen. Lenis measureswindow.innerHeight, the small viewport;100vhis the large one. On mobile the difference is a permanent scroll the page has no content to fill.
Verified on a production build: seven consecutive client-side navigations,
wheel responding on every one, with <html> tracking real content height
each time (5476, 945, 5054, 945, 5549, 945, 5885 px). Reduced motion still
mounts no Lenis instance and scrolls natively. The command palette's
overflow lock releases cleanly across eight open/close cycles.
Pattern
The hydration post-mortem closed on "nothing was watching." This one is narrower and more actionable: everything was watching the wrong thing. Tests, audits and manual passes all existed and all passed, because every one of them tested a page in isolation. The bug lived in the transition between two pages, which no check ever performed.
The 106 assertions in lib/ had also sat unexecuted for two months —
there was no test script in package.json and no CI step to run one. That
is fixed now, and the deploy is gated on lint, typecheck, tests and a
build. None of that would have caught this one. A check that navigates
would have.