---
title: Smooth Scroll
description: Mounts Lenis for the page and renders nothing. Respects reduced motion.
source: https://adamjurek.com/components/smooth-scroll
---

## Home


`SmoothScroll` mounts Lenis for the whole document and renders nothing itself -
what changes is how the page feels under the wheel, easing instead of jumping
frame to frame. Mount it once near the root; anyone with reduced motion set
gets the browser's own scroll instead, which is correct for them.

<!-- ::start:showcase demo="smooth-scroll" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

It mounts inside an effect rather than at module scope, because Lenis
touches `window` and `document` immediately, and the root component that
renders it also renders on the server. Effects only run in the browser, so
the server render and the first client render both stay untouched, and Lenis
only takes over once there is an actual document for it to take over.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/smooth-scroll.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/smooth-scroll.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | motion · Scroll effects |
| Files | `smooth-scroll.tsx` |
| Dependencies | `lenis@1.3.26` |
| Tags | scroll, lenis |


## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { SmoothScroll } from "@sushindustries/ui";

export function RootLayout({ children }: { children: React.ReactNode }) {
	return (
		<>
			<SmoothScroll />
			{children}
		</>
	);
}
```

Mount it once, near the root of the app - it takes no props and needs no
container, because it takes over the whole document's scroll rather than a
piece of the page.

## What you should see

Nothing new in the markup - `SmoothScroll` renders `null`. What changes is
how the page feels under the wheel or a trackpad: scrolling eases in and
out instead of jumping frame to frame. Anyone with `prefers-reduced-motion:
reduce` set gets the browser's plain native scroll instead, which is
correct and not a bug to chase.

## If nothing happens

Mounting it twice is the real failure mode - two `Lenis` instances fight
over the same wheel events, and the scroll stutters rather than smooths.
If the page still feels like native scrolling and reduced motion is off,
check that the component is mounted at all; it does not warn when it is
missing, it just leaves the browser's default in place.


## Guides


## Mount it once, at the root

`SmoothScroll` owns the whole document's scroll the moment it mounts - there
is no scoping prop, because Lenis intercepts wheel and touch for the page,
not for a subtree. Put it once near the top of the app. A second instance
does not stack; it competes with the first for the same events.

## The scroll veil

An iframe or a canvas swallows wheel events that pass over it, so a scroll
gesture that crosses one loses its stream mid-flight - the flutter every
page full of live previews had before this existed. While a scroll is in
flight, `SmoothScroll` sets `data-scrolling` on `<html>`, and the stylesheet
turns embedded surfaces `pointer-events: none` for exactly that long:

```css
[data-scrolling] iframe,
[data-scrolling] canvas {
	pointer-events: none;
}
```

The gesture stays whole, and the previews are interactive again the instant
the page settles - about 150ms after the last scroll event.

## Opting an element out

Some elements need the browser's own scroll, not Lenis's: a table that
scrolls sideways in its own frame, a code block, a drawer. Those carry
`data-lenis-prevent`, which tells Lenis to leave that subtree alone
entirely rather than steal its wheel and touch events. `Table` sets this
attribute on its own frame for exactly this reason - drag inside a wide
table and the table scrolls, not the page behind it.


## API


<!-- generated:api -->

## Signature

```ts
SmoothScroll(): null
```

<!-- /generated:api -->

## Notes

No props, on purpose - `duration` (1.1s) and `smoothWheel` are fixed in the
source rather than exposed, because this site has one scroll feel, not a
per-page one. It mounts inside a `useEffect` rather than at module scope, so
the `window` and `document` it touches immediately are never reached during
a server render; the effect also means reduced-motion users never construct
a `Lenis` instance at all; they get the native scroll from the first frame.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="smooth-scroll" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { NavBar, SmoothScroll } from "@sushindustries/ui";

export function RootLayout({ children }: { children: React.ReactNode }) {
	return (
		<>
			<SmoothScroll />
			<NavBar brand={<span className="mono">acme</span>} entries={[]} />
			<main>{children}</main>
		</>
	);
}
```

## What this example is not

It is not scoped to `children`, even though it is written beside them here.
Lenis takes over the whole document's scroll no matter where in the tree
`SmoothScroll` is mounted - putting it beside the layout's other chrome is
a convenience for reading the code, not a boundary the component respects.
