Lenis in Next.js: how 19 lines turned the site's scroll cinematic
The site's scroll was native, hard, with that 'step-step-step' feel on long pages. I dropped in Lenis via ReactLenis root and it locked into the rest of the redesign without hijacking the page, without breaking anchors, and without fighting prefers-reduced-motion.

The site is in the middle of an "agentic futurism" redesign: dark theatre, volumetric light in the hero, editorial typography inside posts. All of that was asking for one missing detail — the scroll.
Native scroll is fine, but on a long page with a heavy hero it gives the game away: each trackpad notch is a discrete step, the page "jumps" between frames, and the "living system" feeling dies the moment a wheel event fires.
Yesterday I dropped Lenis (from the darkroom.engineering crew) in via the official React wrapper, and the site changed texture.
How it was
overflow-y: autoonhtml+ native scroll.- On macOS with a trackpad the inertia was decent. With a mouse wheel it was step-step-step.
- Worse on Windows/Linux: discrete scroll, no easing at all.
- Anchors (
#section) worked, but with that instant "tap" that cut the reading rhythm.
The hero photo, the fog ring, the volumetric light beam — all of that asks for a camera that moves continuously. Native scroll is the opposite of that.
How it is now
lerp 0.1+duration 1.2— scroll becomes a camera with inertia, without ever feeling "stuck".- Anchors still work (Lenis intercepts programmatic scroll and animates it too).
Page Up/Down,Space, arrows andHome/Endkeep working.- Everything else in the redesign — sticky header, hero, lists — kept behaving. Zero collateral damage.
The whole setup
Install the package:
bun add lenisCreate an isolated client component wrapper:
// src/components/fx/smooth-scroll.tsx
"use client";
import { ReactLenis } from "lenis/react";
import type { ReactNode } from "react";
export function SmoothScroll({ children }: { children: ReactNode }) {
return (
<ReactLenis
root
options={{
lerp: 0.1,
duration: 1.2,
smoothWheel: true,
}}
>
{children}
</ReactLenis>
);
}Plug it into LocaleLayout (App Router, with next-intl):
// src/app/[locale]/layout.tsx
<NextIntlClientProvider>
<SmoothScroll>
<Atmospheric />
<Header />
<main className="flex-1">{children}</main>
<Footer />
</SmoothScroll>
</NextIntlClientProvider>Three decisions that matter:
root— without it, Lenis creates its own container andhtml/bodyare out of reach. Withroot, it attaches todocumentElementand the global scroll is the page scroll.- Low
lerp(0.1) — the per-frame interpolation factor. Lower = more "camera".0.1lands between "responsive" and "buttery". Above0.15it starts to feel sluggish. duration 1.2— used by programmaticscrollTo(anchors, "back to top"). It matches the natural scroll inertia and avoids the over-cinematic "jump cut" feeling.
Why ReactLenis instead of useLenis inside useEffect
I had two options:
useEffect(() => {
const lenis = new Lenis({ /* ... */ });
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
return () => lenis.destroy();
}, []);Or:
<ReactLenis root options={{ /* ... */ }}>{children}</ReactLenis>ReactLenis is exactly the useEffect boilerplate above — it instantiates, registers the raf loop, cleans up on unmount — except it lives inside React's lifecycle and exposes a LenisContext so descendants can call useLenis() or useScroll() when they need to. Less code, less chance of leaking a listener during hot-reload.
What I was afraid of and didn't happen
- Breaking heading anchors. Didn't. Lenis intercepts internal navigation and animates the
scrollTowith the same duration. - Fighting
scroll-margin-top. Still respected. The offset math runs ondocumentElement, so the existing CSS for the sticky header kept working. - Bundle size. The package is ~7 KB gzipped. It sits below the noise floor in Web Vitals.
- Killing
prefers-reduced-motion. Lenis reads the media query and disables smoothing automatically when the user asked for less motion. No conditional on my side.
Where Lenis doesn't fit
- CSS scroll-snap. If you rely on
scroll-snap-typein a section, Lenis can fight it. Solved by isolating that section outside the Lenis context or togglingsmoothWheeloff by class. - Embeds with their own scroll.
iframe,<textarea>, maps. Lenis only controls the root scroll — embeds stay native. That's what I wanted. - Sites where "read fast" is the product. Dense docs, internal search with many results. Native is better there — the user wants to fly through the page, not float in it.
Verdict
The difference is one of those changes that feels cosmetic until you use the site for thirty seconds and realize the reading rhythm changed. 19 lines of code + two layout touches = a scroll that respects the rest of the design's tone.
For sites with a strong hero, subtle parallax, or any "cinematic" ambition, Lenis is an absurd cost/benefit win. For a docs site, I wouldn't ship it. For my blog/portfolio? Should have done it months ago.
Repo PR: #126.
May 16, 2026 · Brazil