Smooth Scroll

An opt-in wrapper that eases mouse wheel scrolling and leaves every other way of scrolling native.

Examples

Smooth and native side by side

Use a mouse wheel over the text

Six weeks at Harbor Lane

Harbor Lane started as a spreadsheet. Three volunteers kept the stall bookings for the Saturday market in one shared file, and every Friday night someone overwrote someone else.

When the market grew to sixty stalls, the file stopped working. Traders called to ask whether their pitch was confirmed, and the answer was often a guess.

We spent the first week watching. Léa sat at the welcome desk with a notebook and wrote down every question a trader asked before nine in the morning.

Most questions were the same four: where is my pitch, can I swap with a neighbour, when do I pay, and who do I call if it rains.

The new booking page answers those four questions on one screen. A trader sees the map of the street, their pitch in the brand colour and the neighbours on either side.

Swapping became a request instead of a phone call. The other trader gets a message, accepts or declines, and the map updates for both of them.

Payment moved to the end of the week. Kofi argued for it after noticing that most traders waited for the weather forecast before committing.

The rain plan is a single switch for the organisers. When it is on, every trader gets the covered pitch assigned to them and a short note with the new time.

We kept the spreadsheet as an export. The volunteers still like to print it on Saturday morning, and nothing about the new tool should take that away from them.

Six weeks in, the Friday night calls have stopped. The welcome desk now hands out coffee instead of answers.

Nested scrolling areas

Saturday orders

The list of orders below scrolls on its own, with the native feel of the system. The page around it is smoothed.

When the list reaches its end, the wheel moves on to the page, as it would without smoothing.

Anything marked with data-smooth-scroll-ignore keeps the native wheel too, which suits maps and canvases that zoom with it.

  • #2048Bakery on the corner€42.00
  • #2047Fernhill farm shop€18.50
  • #2046Second-hand books€9.00
  • #2045Cheese and olives€27.80
  • #2044Flower stall€15.00
  • #2043Coffee cart€6.40
  • #2042Ceramics by Ana€64.00
  • #2041Honey from the hills€11.20
  • #2040Bike repairs€25.00
  • #2039Fish market€33.60
The wheel here stays native

The rest of the page goes on below, smoothed again.

Installation

npx shadcn@latest add https://ui.ndekocode.dev/r/motion-smooth-scroll.json

Component API

SmoothScroll

Only the mouse wheel is eased. The page keeps its real scroll position at every frame, so sticky elements, scroll-linked animations, anchors, the keyboard, the scrollbar, touch and screen readers behave as they do without it. When something else moves the page, the easing stops and starts again from there. With reduced motion, nothing is attached at all.

The wheel stays native when it is pinched to zoom, sideways or held with Shift, inside an inner area that can still scroll that way, inside an element marked data-smooth-scroll-ignore, and while the page is locked by a dialog.

Use root on a marketing page to ease the window; without it, the wrapper is the scrolling area and needs a height.

PropTypeDefaultDescription
childrenReact.ReactNodeThe content.
rootbooleanfalseEases the scrolling of the window instead of the wrapper.
lerpnumber0.12The share of the remaining distance covered in each frame at 60 fps, between 0.01 and 1.
enabledbooleantrueTurns the easing on or off without unmounting.
classNamestringundefinedClasses of the wrapper, merged over relative overflow-y-auto when it scrolls itself.

useSmoothScroll

The same behaviour as a hook, for a scrolling element you already have.

OptionTypeDefaultDescription
containerRefObject<HTMLElement | null>The scrolling element. Leave it out for the window.
lerpnumber0.12As above.
enabledbooleantrueAs above.