Craft

Smooth scrolling without the regret

Smooth scrolling is a feature people only notice when it goes wrong. A page glides pleasantly for a week, and then someone presses the spacebar, searches with Ctrl+F, or opens a menu inside a long list and nothing behaves like an ordinary web page. Before adding any of it, decide which kind you are adding, because two different things are sold under one name.

Two different things

Eased programmatic scrolling changes how the page moves when your code or a link asks it to move: clicking an in-page link, calling scrollTo, jumping to the top. MDN defines scroll-behavior as applying to scrolling triggered by navigation or the CSSOM scrolling APIs, and adds that scrolls performed by the user are not affected. It also says user agents are allowed to ignore the property, and that on the root element it applies to the viewport while on body it does not propagate. In CSS it is a single rule:

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

Wheel-driven smoothing takes over input. The library receives the wheel delta, cancels the browser's default scroll, keeps its own target position and moves the page toward it on every animation frame. This is what Lenis does, and it is also where the risk sits. If all you want is a gentle jump to a section, use the CSS above and stop reading here.

What Lenis actually does

Claims about "smooth scroll libraries" in general age badly, so here is one library, checked against the version this site pins (Lenis 1.3.26, its README and its source, read on 2026-10-17):

  • It registers wheel, touchstart, touchmove and touchend listeners with passive: false, and calls preventDefault() on a wheel event when it is smoothing. It then moves the page with scrollTo on a scroll container. It registers no keydown listener, so keyboard scrolling stays native.
  • The README describes it as running on native scroll, so that position: sticky, anchor links and accessibility keep working. It also says that, by default, it prevents anchor links from working while scrolling, and that you must set anchors: true to enable them.
  • Defaults documented in the README: smoothWheel on, lerp of 0.1, wheelMultiplier of 1, syncTouch off, anchors off, allowNestedScroll off, autoRaf off (you either set it or call lenis.raf(time) each frame), and respectReducedMotion on. With the last one, smoothing is disabled under reduce, programmatic scrolls become instant, and the preference is picked up live.
  • Documented limits: no support for CSS scroll-snap (a separate snap package exists), Safari is capped at 60 frames per second, and smoothing stops working over an iframe because iframes do not forward wheel events.

The performance note matters. MDN says wheel and touch listeners on Window, Document, document.documentElement and document.body default to passive so the browser can scroll without waiting for your script. A listener that cancels the event has to opt out with passive: false, which is what Lenis does. The browser may then have to wait for the handler before it can scroll, a cost that shows up when the main thread is busy with canvas work.

How long the glide really lasts

The default lerp of 0.1 is easy to misread as "ten percent of the distance per frame". In the 1.3.26 source the animation uses frame-rate independent damping, damp(value, target, lerp * 60, deltaTime), with the time in seconds. The remaining distance after t seconds is therefore exp(-6t) for the default. Computed from that formula:

lerpDistance left at 0.25 s5 percent left after1 percent left after
0.25 percent0.25 s0.38 s
0.1 (default)22 percent0.50 s0.77 s
0.0547 percent1.00 s1.54 s

So a single wheel notch keeps the page moving for most of a second at the default setting, and for a second and a half at 0.05. That is the "floaty" feel, and it is a number you can choose. Short smoothing keeps the page close to the hand. Long smoothing is what makes people feel the page lags behind them.

The race behind most bugs

The glide window above is also the window in which native scrolling and the library disagree. In the 1.3.26 source, the handler for native scroll events copies the browser's position into the library's target only when the library is not in the middle of its own animation (isScrolling is false or "native"). While a glide is running, a native jump is not adopted as the new target. By reading the code, the likely result is that the glide carries on toward the old target and overrides the jump. That is a reading of the source, not something to take on trust, so build a test around it: roll the wheel once, and within the next half second press End, or press Enter in the find-in-page bar, or Tab to a link near the bottom. Repeat after the glide has fully settled and compare. If the two runs differ, you have found the class of bug that shows up as "the page jumped back after I searched".

Two consequences follow. A shorter glide shrinks the window. And any script of yours that moves the page, such as a route change that scrolls to the top, should go through the library's scrollTo with immediate: true (a documented option that ignores duration, easing and lerp), so that its target and the real position never diverge.

What breaks, and how to check it

Libraries differ and versions change, so treat this as a test routine. Each row is something to try on your own build.

Native behaviourWhat can go wrongHow to test
Keyboard scrollingSpace or Page Down jumps instantly while the wheel glides, or the two fight.Press Space, Page Down, End, Home and the arrows in sequence on a long page.
Find-in-pageThe match is highlighted but the page lands in the wrong place, or the animation snaps back.Ctrl/Cmd+F, search a word near the bottom, press Enter repeatedly.
Anchor links and #hash on loadLands under a sticky header, or does not scroll at all.Load /page/#section directly and click in-page links.
FocusTabbing to an off-screen link scrolls natively but the library's internal target is stale, so the next wheel tick jumps back.Tab through the page with the keyboard, then use the wheel once.
Nested scrollersA scrollable menu, code block or modal moves the page behind it.Open the element and scroll with the cursor over it.
TouchAdded latency, lost momentum, rubber-banding that disagrees with the page.Test on a real phone, not only DevTools emulation.
Reduced motionThe glide continues for people who asked for less motion.Turn on the OS setting and reload.
Back and forwardThe page reopens at the top, or restores and then animates.Scroll, follow a link, press Back.
Zoom and resizeCached scroll limits are wrong after the page changes size.Zoom to 200 percent and resize the window.

A row that fails is a decision: fix it, scope the library away from that case, or drop the library.

How to scope it

Grant smooth scrolling to the situations that benefit; do not install it globally.

Gate on preference and pointer. Lenis already honours prefers-reduced-motion, but a page that adds its own scroll-linked work benefits from one switch it controls. (hover: hover) and (pointer: fine) matches devices whose primary input is an accurate pointer that can hover, such as a mouse; a phone's primary input is coarse, so it stays native. Gate creation on both and make it reversible:

import Lenis from 'lenis';

const calm = matchMedia('(prefers-reduced-motion: no-preference)');
const mouse = matchMedia('(hover: hover) and (pointer: fine)');

let lenis = null;
function sync() {
  const wanted = calm.matches && mouse.matches;
  if (wanted && !lenis) lenis = new Lenis({ autoRaf: true, anchors: true, allowNestedScroll: true });
  if (!wanted && lenis) { lenis.destroy(); lenis = null; }
}
calm.addEventListener('change', sync);
mouse.addEventListener('change', sync);
sync();

Exclude inner scrollers. The README gives two routes. allowNestedScroll: true detects scrollable descendants and lets them scroll natively, with a warning that it checks the DOM tree on every scroll event. The cheaper route is an attribute on the element: data-lenis-prevent for everything, or the -wheel, -touch, -vertical and -horizontal variants. The recommended lenis.css also sets overscroll-behavior: contain on those elements. MDN describes contain as preventing scrolling from continuing outside the scroll container, so a flick at the end of a menu does not drag the page.

Pause for overlays. lenis.stop() pauses the scroll and lenis.start() resumes it. Call stop() when a modal opens and start() on close, and keep the opt-out attribute on the modal's own scrolling area.

Keep native for the short and the dense. Documentation, long articles that readers search, forms and anything read slowly gain nothing from glide. Apply it to a showcase and leave the reading pages native.

Fix anchors in CSS. A sticky header covers the target of an anchor jump. MDN describes scroll-padding as offsets that define the optimal viewing region of the scrollport, the target region used when placing elements in view, and its example sets it on the root element for a fixed header. That covers the browser's own fragment jumps, including a hash in the URL on load:

html { scroll-padding-top: 5rem; }   /* the height of the sticky header */

For clicks that Lenis handles with anchors: true, the 1.3.26 source computes the target itself. When the target is an element or an id, it subtracts the target's scroll-margin-top and the root's scroll-padding-top, so the same CSS applies. A plain number passed to scrollTo gets neither, and its offset option is simply added to the target, so a header needs a negative value there. Test both paths: click an anchor, and load a URL with the hash directly.

Measure what it costs

Treat the library as a change you have to justify with numbers from your own page. Record the same 10-second scroll twice in the DevTools Performance panel, once with the library running and once with it destroyed, on a mid-range laptop with the CPU throttled. Compare the number of long tasks, the dropped frames and the time the main thread spends in your scroll handlers. If the smoothed version drops frames the native one does not, the cause is usually work you attach to every scroll tick (layout reads, canvas updates, ScrollTrigger callbacks), and smoothing only makes it visible. Fix that work first. If the page still stutters with the library off, smoothing was never the cause and removing it changes nothing.

Then ask someone who did not build the page to read it for a minute with the library on, without saying what to look for. If they mention the scrolling unprompted ("it feels heavy"), that is worth more than any frame counter.

Scroll-linked effects do not require a scroll library

Many sites install a smooth scroller to make parallax and pinned sections look good. Scroll-linked effects work from the scroll position, native or not. You can read it in a scroll listener (MDN notes that the scroll event cannot be cancelled, so a listener cannot block scrolling) or use an IntersectionObserver for reveals. Smoothing the input only makes the numbers change more gradually. If an effect looks rough on native scrolling, a smoother input will hide the roughness, and you will have taken on every risk above to do so.

Motion that scrolling causes is a named concern. The WCAG 2.2 Understanding page for success criterion 2.3.3 (Animation from Interactions, level AAA) says that if scrolling a page causes elements to move, other than the essential movement of scrolling, it can trigger vestibular disorders, and it singles out parallax as often non-essential. The criterion asks that motion animation triggered by interaction can be disabled unless it is essential. A wheel-driven glide is not essential, so the reduced-motion gate is the minimum. prefers-reduced-motion, done properly covers what else should change.

Checklist

  • Choose the kind first: CSS scroll-behavior for jumps, a library only if you want to change wheel feel.
  • Start it only for prefers-reduced-motion: no-preference and a fine pointer, and be able to destroy it at runtime.
  • Exclude inner scrollers, pause it while overlays are open, and keep the lag short: compute how long your lerp takes to settle.
  • Set scroll-padding-top for sticky headers and test hash links on load.
  • Run the table above (keyboard, find-in-page, anchors, focus, back and forward, touch) before shipping and after every library upgrade.

Questions and answers

Is CSS scroll-behavior: smooth the same as a smooth-scroll library?

No. MDN describes the property as applying to scrolling triggered by navigation or the CSSOM scrolling APIs, and says scrolls performed by the user are not affected. A wheel-driven library such as Lenis listens to wheel and touch input and moves the page itself.

Does smooth scrolling hurt accessibility?

It can. Motion tied to scrolling is a documented vestibular trigger, and a library that intercepts input has to cooperate with keyboard, find-in-page and assistive technology paths. Honour prefers-reduced-motion and test each native way of scrolling after every library upgrade.

Should smooth scrolling run on phones?

Usually not. Native touch scrolling has momentum people already know. Lenis documents syncTouch as off by default and its README notes that touch can behave unexpectedly on iOS when it is on, so leaving touch native is the documented default.

How do I stop a smooth-scroll library from affecting a modal or a code box?

Use the library's documented opt-out. Lenis offers data-lenis-prevent attributes and an allowNestedScroll option for inner scrollers, and stop() and start() for overlays. Without one, wheel events inside the inner element can move the page behind it.

Sources

  1. MDN: scroll-behavior developer.mozilla.org
  2. MDN: EventTarget.addEventListener (passive listeners) developer.mozilla.org
  3. MDN: scroll-padding developer.mozilla.org
  4. Lenis smooth scroll library (repository and documentation) github.com
  5. WCAG 2.2: Understanding Animation from Interactions (2.3.3) w3.org