Lenis smooth scroll
Lenis intercepts wheel input and eases the native scroll position. Sticky positioning,
IntersectionObserver, and window.scrollY all keep working — which is why it's the
right choice. The failures come from things that fight it, not from Lenis itself.
Setup
Vendor it rather than hot-linking a CDN, so the site stays self-contained and works from
file://:
curl -sSL -o assets/vendor/lenis.min.js https://unpkg.com/[email protected]/dist/lenis.min.js
The dist/lenis.min.js build is an IIFE exposing globalThis.Lenis — fine in a plain
<script>. (dist/lenis.mjs is ESM and will not work in a plain script tag.)
Load it before your own script.
var reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
var lenis = null;
if (!reduced && window.Lenis) {
var navH = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--nav-h'), 10) || 0;
lenis = new Lenis({
duration: 1.05,
easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
syncTouch: false, // leave touch native — smoothing it feels laggy on phones
autoRaf: true, // Lenis runs its own RAF loop; no manual ticker needed
anchors: { offset: -(navH + 10) } // in-page links clear the sticky header
});
} else {
// no Lenis — give the browser its own behaviour back
document.documentElement.style.scrollBehavior = reduced ? 'auto' : 'smooth';
}
Required CSS
html { /* NO scroll-behavior: smooth — it fights Lenis */ }
html.lenis, html.lenis body { height: auto; }
.lenis:not(.lenis-autoToggle).lenis-stopped { overflow: clip; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-smooth iframe { pointer-events: none; }
scroll-behavior: smooth on html must go. If you keep it, anchor jumps double-animate
and fight the easing.
Trap 1 — overflow: clip kills position: sticky
This is the one that will cost you an hour.
lenis.stop() adds .lenis-stopped, whose CSS sets overflow: clip on <html>. That
removes the scrollport, and position: sticky has nothing left to stick to — sticky
elements snap back to their position in the document, i.e. the top of the page.
Open a menu at scroll 1200 and your sticky header renders at top: -1200. Off screen.
Along with its close button. Users report it as "the close button disappeared."
The same thing happens with the classic body { position: fixed } scroll lock, for the
same reason — no scrollport.
Fix: pin the header to the viewport for as long as scrolling is locked.
html.menu-open .nav-wrap { position: fixed; top: 0; left: 0; right: 0; }
document.documentElement.classList.toggle('menu-open', open);
Swap the class while the overlay is still opaque so the sticky→fixed layout change is invisible. Apply it on both the Lenis and the reduced-motion paths.
Test overlays at a non-zero scroll offset. At scroll 0 a broken sticky header is pixel-identical to a working one, so the bug hides completely. Verify at 0, ~1200, and deep in the page.
Trap 2 — don't stack two scroll locks
Pinning the body and calling lenis.stop() makes them fight: position: fixed on body
collapses the page height, Lenis resyncs to 0, and closing the overlay dumps the user at
the top of the page.
With Lenis, stop() plus its own overflow: clip holds the page without touching the
body — so the scroll position is never lost and there is nothing to restore:
if (lenis) {
open ? lenis.stop() : lenis.start();
} else if (open) { // reduced-motion path only
scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = -scrollY + 'px';
document.body.style.width = '100%';
} else if (document.body.style.position === 'fixed') {
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, scrollY);
}
Verify: scroll to 1200 → open → wheel → close. Position must read 1200 at every step, and scrolling must work again afterwards.
Trap 3 — anchor links
Lenis owns scrolling, so raw href="#id" jumps look wrong. Use the built-in anchors
option with a negative offset equal to your sticky header height, or route clicks
manually through lenis.scrollTo(target, { offset: -navH }).
Check the landing position: the section top must sit at or below the header's bottom edge, not underneath it.
Trap 4 — Lenis is usually not your jank
When scroll feels bad after adding Lenis, measure before blaming it. In one build native scroll actually measured worse (13% dropped frames vs 9%) — Lenis was fine and the real costs were elsewhere:
- a fixed full-viewport
mix-blend-modeoverlay (film grain): 34% → 3% dropped frames just by removing the blend mode backdrop-filteron a sticky element, which is on screen for every frame of every scrollbackdrop-filteron large elements generally — it re-samples per frame
Frame-timing harness
// in page
window.__f = []; let l = performance.now();
const t = n => { window.__f.push(n - l); l = n; requestAnimationFrame(t); };
requestAnimationFrame(t);
Drive it with real wheel events over the suspect section, then report mean, p95, and the
percentage of frames > 33ms. Bisect by disabling one effect at a time with
add_style_tag. Healthy is ~16.7ms mean and 0% long frames; anything over ~10% long
frames is visible stutter.
Checklist
-
scroll-behavior: smoothremoved fromhtml - Lenis CSS block added
-
autoRaf: true(or a manual RAF loop — not both) -
anchorsoffset matches the sticky header height -
syncTouch: falseso phones keep native momentum - Sticky headers pinned while any overlay locks scrolling
- Only one scroll-lock mechanism in play
- Overlays tested at a non-zero scroll offset
-
prefers-reduced-motionskips Lenis and restores native behaviour - Scroll position preserved across open → close
- Frame timing measured, not assumed