TG
frontend·react·nextjs·5 min read

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.

Ler em português
Lenis in Next.js: how 19 lines turned the site's scroll cinematic

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: auto on html + 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 and Home/End keep working.
  • Everything else in the redesign — sticky header, hero, lists — kept behaving. Zero collateral damage.

The whole setup

Install the package:

bun add lenis

Create 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:

  1. root — without it, Lenis creates its own container and html/body are out of reach. With root, it attaches to documentElement and the global scroll is the page scroll.
  2. Low lerp (0.1) — the per-frame interpolation factor. Lower = more "camera". 0.1 lands between "responsive" and "buttery". Above 0.15 it starts to feel sluggish.
  3. duration 1.2 — used by programmatic scrollTo (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 scrollTo with the same duration.
  • Fighting scroll-margin-top. Still respected. The offset math runs on documentElement, 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-type in a section, Lenis can fight it. Solved by isolating that section outside the Lenis context or toggling smoothWheel off 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.

Thiago Marinho

May 16, 2026 · Brazil