krishna@
9 min read#web

Lenis + Next.js: smooth scroll without breaking sticky

Adding smooth scroll to a Next.js site is one of those changes that feels free for the first hour and then breaks half your layout. Here is the catalogue of what breaks and how to fix each one.

share
Plants and a laptop on a clean desk

the smooth-scroll trap

Adding smooth scroll to a Next.js site is one of those changes that feels free for the first hour and then breaks half your layout in subtle ways. Lenis (and ScrollSmoother, and Locomotive Scroll, and every other smooth-scroll library) doesn't actually scroll your page — it intercepts the scroll event and lerps a fake scroll position, then transforms the page content to match.

This is fine until something else on the page also depends on scroll position. position: sticky. Intersection observers. scroll-margin-top. scroll-snap. CSS scroll-driven animations. None of these know about the lerp. They read the real scroll position, which is not what's visually on screen.

This post is the catalogue of what breaks and how to fix each one, written after I broke them all on this site.

what Lenis actually does

Conceptually:

  1. Lenis listens for wheel/touch events.
  2. It updates an internal target scroll value.
  3. On every requestAnimationFrame, it interpolates from current to target with a configurable easing.
  4. It then either scrolls the page natively to the interpolated value (the default in newer Lenis versions), or sets a CSS transform on a wrapper to fake the scroll.

In the native mode (the recommended one), window.scrollY is correct — Lenis is just providing smooth easing on what would otherwise be an instant jump. Most things work.

In the transform mode (older or some niche setups), window.scrollY is wrong — it always reads 0 or whatever the wrapper's transform offset is. Most things break.

I run Lenis in native mode (ReactLenis root in the React integration). That covers most issues. The remaining ones are about edge cases.

the issues, in order of severity

overflow-x: hidden on html, body

Most projects ship overflow-x: hidden on html, body to prevent horizontal scroll from a stray full-bleed element. With Lenis, this creates a new scroll container that breaks position: sticky everywhere downstream.

Symptom: position: sticky works fine without Lenis, then stops sticking when you add it. Happens silently — no error, just sticky elements scroll with the page.

Fix: replace with overflow-x: clip:

html, body {
  overflow-x: clip; /* not hidden */
}

clip is the modern alternative that prevents horizontal overflow without creating a scroll container. Browser support is solid (Safari 16+, Chrome 90+, Firefox 81+). For older browsers, fall back to hidden and accept that sticky won't work for those users.

Sticky elements that stretch

Even with overflow-x: clip, a sticky element inside a CSS Grid will sometimes refuse to engage. The reason: the grid stretches its child to the row's full height by default, leaving no scroll room for the sticky to slide.

Symptom: a sidebar with position: sticky; top: 24 inside a grid scrolls with the page even though the rule is right.

Fix: align-self: start on the sticky element:

<div className="grid lg:grid-cols-12">
  <article className="lg:col-span-8"> ... </article>
  <aside className="lg:col-span-4 lg:sticky lg:top-24 lg:self-start">
    ...
  </aside>
</div>

self-start opts the sticky element out of the row's stretch behavior. It now only takes its content's height, which is shorter than the article's, which gives sticky room to engage.

Lenis intercepts wheel and touch events but not programmatic scrolls (element.scrollIntoView, window.scrollTo). Anchor links by default trigger an instant jump.

Fix: opt anchor links into Lenis:

import { Lenis } from 'lenis';

document.addEventListener('click', (e) => {
  const target = e.target as HTMLElement;
  const link = target.closest('a[href^="#"]') as HTMLAnchorElement | null;
  if (!link) return;
  const id = link.hash.slice(1);
  const el = document.getElementById(id);
  if (!el) return;
  e.preventDefault();
  lenis.scrollTo(el, { duration: 1.2 });
});

Or, if you're using Lenis's React integration, it provides a useLenis hook that exposes lenis.scrollTo.

Intersection observers fire at the wrong time

Less critical, but worth knowing: an Intersection Observer measures intersection at the real scroll position, which Lenis is in the process of interpolating toward. So observers fire slightly before the user visually reaches the trigger element.

For most uses (lazy loading, reveal animations), the slight lead is unnoticeable. For tight scroll-driven animations, you may want to drive them off Lenis's animation frame rather than IntersectionObserver:

lenis.on('scroll', ({ scroll, progress }) => {
  // scroll: actual scroll position (matches window.scrollY)
  // progress: 0..1 along the page
});

Programmatic scroll-into-view from a route change

Next.js's default behavior on route change is to scroll to top. It does this with window.scrollTo(0, 0) synchronously before paint — Lenis's lerp doesn't apply, and the user sees an instant jump. This is the right behavior 90% of the time.

The 10% case: you have an in-page anchor in the destination URL (/blogs#section) and you want a smooth scroll to that anchor on first paint. The default behavior is an instant jump.

Fix: Next 16's <Link scroll={false}> plus a useEffect in the destination page that does an explicit lenis.scrollTo(hash) after mount.

the Next.js setup

The integration in this site:

// providers/SmoothScrollProvider.tsx
'use client';

import { ReactLenis } from 'lenis/react';

export const SmoothScrollProvider = ({ children }: { children: React.ReactNode }) => (
  <ReactLenis
    root
    options={{
      duration: 1.0,
      easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
      smoothWheel: true,
      smoothTouch: false, // touch devices have native smooth; let them be
    }}
  >
    {children}
  </ReactLenis>
);

Three details to highlight:

root — tells Lenis to control the document scroll, not a wrapper. This is the native mode mentioned earlier. window.scrollY works correctly.

Custom easing — the default Lenis easing is too aggressive for my taste. The 1.001 - 2 ** (-10 * t) curve is gentler and more like an iOS-style decel.

smoothTouch: false — touch devices already have native momentum scrolling that's tuned for their input device. Lenis fighting iOS's native scroll feels worse than letting iOS handle it. Desktop wheel is where Lenis wins.

the testing surface

Smooth scroll is the kind of feature that's hard to test automatically. The eyes are the test. But there are a few categorical checks:

Manual sweep on every page — does scroll feel right? Does sticky work? Do anchor links smooth? Do form-submit redirects work?

Real device test — iOS Safari, Android Chrome. Touch devices behave differently from desktop wheel.

Accessibility checkprefers-reduced-motion should disable Lenis entirely. The library has a config flag for this; verify it's wired:

<ReactLenis
  options={{
    smoothWheel: !window.matchMedia('(prefers-reduced-motion: reduce)').matches,
  }}
>

This is the kind of thing where users with vestibular disorders or motion sensitivity will silently leave your site if you don't handle it. Worth the four lines.

the operational reality

Smooth scroll is a feel feature, not a function feature. The site works without it. The point is the tactile quality — the way the page settles after a wheel event, the way a long article scrolls without the staircase feel.

Done well, nobody notices. Done badly, every interaction feels off in a way that's hard to articulate.

The investment to do it well, on a Next.js site:

  1. Add Lenis with ReactLenis root.
  2. Replace overflow-x: hidden with clip.
  3. Add self-start to your sticky elements.
  4. Wire anchor links to lenis.scrollTo.
  5. Respect prefers-reduced-motion.

About a day, end-to-end, including the bug-find on overflow-x (which will take you longer than it should — it took me longer than it should).

the meta-point

Smooth scroll is in the category of UI improvements where the cost-benefit is asymmetric: cheap to add poorly, hard to add well. Most sites that ship it ship the poorly-added version, and the result is a site that has the idea of smooth scroll but feels worse than no smooth scroll.

The version that's worth shipping is the one with all the integration work done — sticky working, anchors smoothing, reduced-motion respected, the lot. That version is invisible to users in the right way: the site just feels nicer to scroll, and they can't tell you why.

That's the version this site has. The cost was a day of debugging overflow-x. Worth it.


by Krishna Adhikari · Apr 28, 2026
share
// related.transmissions

Keep reading.