I run Lenis on this portfolio for smooth in-page scrolling. It works well for long case-study pages and the writing index. The trade-off is familiar: Lenis listens to wheel events on the document and tries to own vertical movement. Any panel that should scroll independently — a mobile menu, a command palette list, or a sheet — must opt out on the correct axis. Get that wrong and either the panel feels stuck or the page stops scrolling when the pointer happens to be over a wide code block.
The fix is not to disable Lenis globally. It is to mark nested scroll surfaces with the right attribute for the gesture direction, and to use a shared CodeBlock component for samples so horizontal overflow never carries full data-lenis-prevent.
What Lenis is doing
Lenis is initialized in a client component that loads after idle time (and only when the user has not requested reduced motion). The instance uses smoothWheel: true, allowNestedScroll: true, and a prevent callback. Lenis walks the event composed path; when a node opts out for that gesture, Lenis does not call preventDefault and native overflow scrolling can run.
// src/components/lenis-init.tsx (excerpt)
const lenis = new Lenis({
smoothWheel: true,
allowNestedScroll: true,
prevent: (node) =>
node instanceof HTMLElement && shouldLenisPreventScroll(node),
});Vertical panes vs code blocks
Vertical nested scroll (command menu, mobile drawer, sheets): use data-lenis-prevent or className="lenis-prevent" with overflow-y-auto and a max height. The prevent callback only treats .lenis-prevent as a full opt-out when the element can actually scroll vertically, so a horizontal-only node does not trap the wheel by accident.
Horizontal-only code: use the shared CodeBlock / BlogPre wrapper — overflow-x-auto plus data-lenis-prevent-horizontal. Vertical wheel over the block smooth-scrolls the page; shift+wheel or a sideways trackpad gesture scrolls the snippet. Do not put data-lenis-prevent on those blocks.
// src/lib/lenis-prevent.ts (excerpt)
export function shouldLenisPreventScroll(node: HTMLElement): boolean {
if (node.hasAttribute("data-lenis-prevent")) return true;
if (node.classList.contains("lenis-prevent")) {
return node.scrollHeight > node.clientHeight + 1;
}
return false;
}Lenis 1.3+ also understands axis-specific attributes without the callback: data-lenis-prevent-vertical and data-lenis-prevent-horizontal apply only when the wheel delta is primarily vertical or horizontal. Tall code with max-h-* uses the vertical attribute so the interior scrolls first; allowNestedScroll helps hand off to the page at the edge.
Where I apply it on this site
- Mobile navigation — full
data-lenis-preventon the scrolling panel. - Command menu — capped list with full prevention.
- Sheets and dropdowns — Radix content nodes with
data-lenis-prevent. - Code samples —
CodeBlockwith horizontal prevention only (see below). - ScrollArea primitive — vertical nested scroll helper.
<Command.List className="lenis-prevent max-h-80 overflow-y-auto p-2" data-lenis-prevent="" /> // Wide sample — page scrolls vertically over the block <pre data-lenis-prevent-horizontal className="overflow-x-auto">…</pre>
Reduced motion and touch
Lenis does not boot when prefers-reduced-motion: reduce is set. I keep syncTouch: false for document touch behavior. Horizontal swipe on a wide pre still uses native overflow-x scrolling on touch devices because Lenis does not claim that axis on horizontal-only blocks.
Checklist for new UI
- Vertical list in a dialog →
data-lenis-prevent+overflow-y-auto. - Wide code →
CodeBlockordata-lenis-prevent-horizontal, never full prevent. - Tall code →
maxHeightClassonCodeBlock(data-lenis-prevent-vertical).
Full details live in docs/LENIS.md in the repo.