When smooth scrolling fights the browser

Smooth scrolling is one of those features that looks like a one-line install. I added it to a clinic's website, then two days later removed it, and a couple of hours after that added it back. The sequence is worth writing down, because the bug was not where it looked.

The timeline: added, judder, removed, and added back with two safeguards.

What it does

Lenis intercepts wheel input and eases the scroll position toward its target, so a page glides to a stop instead of moving in fixed steps. The setup on the clinic site was the usual one: about a second of ease-out, an offset so anchor links clear the sticky header, native scrolling left alone on touch devices, and no Lenis at all for visitors who prefer reduced motion.

The judder

Soon after, scrolling stuttered. The first suspect was cost: heavy images, video, a lot of paint. The measurements said no. The home page was 186 KB over 33 requests with 504 DOM nodes, and the videos were small. It was not paint cost.

The real cause was two things animating the same scroll. The stylesheet still had html { scroll-behavior: smooth }. With that on, the browser animates toward a scroll target by itself, while Lenis writes scrollTop every frame, so the two pull against each other. Lenis knew about this and had a guard for it:

.lenis.lenis-smooth { scroll-behavior: auto !important }

The guard had worked before, and it stopped working without anyone touching the markup. In Lenis 1.3, the lenis-smooth class changed meaning. It used to be present for as long as smooth mode was on. It became a transient class, present only while a scroll is actually in flight. At rest, which is most of the time, the guard did not apply, and the browser's own smooth scrolling was free to fight.

package.json said "lenis": "^1.3.26". A caret range accepts minor updates, so the behaviour changed on a routine dependency bump, with no change in the site's own code. That is why it looked as though the site had scrolled cleanly until it suddenly did not.

Removing it

The first fix removed the conflict, but the stutter did not entirely go away. The deeper issue is where the scrolling runs. Native scrolling is handled by the browser's compositor, off the main thread, so it keeps up whatever JavaScript is doing. Lenis moves it onto the main thread: every frame is a JavaScript write plus a layout, and any hitch on the main thread shows as stutter. On a large viewport with the graphics hardware busy, it never fully settled.

So Lenis was removed, along with its dependency, and the page went back to native scroll with scroll-behavior: auto. Anchor offsets were already carried by scroll-padding-top, which does not need JavaScript.

Adding it back

A couple of hours later it came back, once the graphics hardware was doing the compositing. This time with the two fixes that had been missing:

html {
  /* Lenis drives the scroll, so the browser's own smooth behaviour stays off. */
  scroll-behavior: auto;
  scroll-padding-top: calc(var(--header-height) + 16px);
}
"lenis": "1.3.26"

The CSS no longer depends on a class the library might redefine, and it also covers the reduced-motion case, where Lenis never starts. The version is pinned exactly, because a silent minor bump is what broke it.

What I would take from it

  1. Set scroll-behavior: auto yourself. Do not rely on the library's class to switch the browser's behaviour off. A guard owned by someone else can change meaning under you.
  2. Pin versions of anything that touches the page's core behaviour. A caret range is fine for a utility. Smooth scrolling changes how the whole page feels.
  3. Know what you are trading. Lenis moves scrolling to the main thread. On a fast machine that is invisible. On a busy one, native scrolling will hold up better.
  4. Treat reduced motion and touch as first-class. Both are handled by leaving native scrolling on.
  5. A stutter is not always the thing it looks like. Measure the page before blaming it.

I use the same library on this site now, with these four things applied: no scroll-behavior: smooth, Lenis disabled for reduced motion, touch left native, and the version pinned.