A Lenis-style smooth scroll viewport built from scratch: wheel input drives a target position that a frame-rate independent lerp chases each frame, with an optional progress bar and live velocity readout that make the inertia visible.
Preview
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add smooth-scrollWrites components/ui/smooth-scroll.tsx and components/styles/smooth-scroll.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { SmoothScroll } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SmoothScroll />6 );7}Props
Accessibility
The viewport is a focusable role="region" with an aria-label, so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native, unsmoothed) and see a 2px :focus-visible outline. Wheel input is smoothed only while the viewport can still move; at the top/bottom edge wheel events are released so the page keeps scrolling. Touch and scrollbar dragging stay native. Under prefers-reduced-motion (or smooth=false) the wheel is not intercepted at all. The progress bar and velocity badge are aria-hidden decorations.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/smooth-scroll.module.css";5 6interface SmoothScrollState {7 /** Current scrollTop of the viewport in px */8 scroll: number;9 /** Maximum scrollTop in px */10 limit: number;11 /** scroll / limit, 0..1 */12 progress: number;13 /** Signed scroll velocity in px per 60fps frame */14 velocity: number;15}16 17interface SmoothScrollOptions {18 lerp: number;19 wheelMultiplier: number;20 smooth: boolean;21 onScroll?: (state: SmoothScrollState) => void;22}23 24const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));25 26function usePrefersReducedMotion(): boolean {27 const [reduced, setReduced] = React.useState(false);28 React.useEffect(() => {29 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");30 const update = () => setReduced(mq.matches);31 update();32 mq.addEventListener("change", update);33 return () => mq.removeEventListener("change", update);34 }, []);35 return reduced;36}37 38/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */39function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {40 let el: Element | null = node instanceof Element ? node : null;41 while (el && el !== root) {42 if (el instanceof HTMLElement) {43 if (el.dataset.mfScrollPrevent !== undefined) return true;44 const overflowY = window.getComputedStyle(el).overflowY;45 if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {46 const canMove = dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1;47 if (canMove) return true;48 }49 }50 el = el.parentElement;51 }52 return false;53}54 55/**56 * Self-contained smooth scroller for an overflow container. Wheel input updates a target57 * position; a rAF loop eases the real scrollTop toward it (frame-rate independent lerp) and58 * stops once settled. Touch, keyboard and scrollbar drags stay native. At the top/bottom edge59 * wheel events are released so the page keeps scrolling.60 */61function useSmoothScroll(62 containerRef: React.RefObject<HTMLElement | null>,63 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,64): React.RefObject<SmoothScrollState> {65 const stateRef = React.useRef<SmoothScrollState>({ scroll: 0, limit: 0, progress: 0, velocity: 0 });66 const callbackRef = React.useRef(onScroll);67 React.useEffect(() => {68 callbackRef.current = onScroll;69 }, [onScroll]);70 71 React.useEffect(() => {72 const el = containerRef.current;73 if (!el) return;74 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);75 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;76 let target = el.scrollTop;77 let current = target;78 let raf = 0;79 let lastTime = 0;80 let lastNative = el.scrollTop;81 let lastNativeTime = 0;82 let settleTimer: ReturnType<typeof setTimeout> | undefined;83 84 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);85 86 const emit = (velocity: number) => {87 const limit = getLimit();88 const scroll = clamp(el.scrollTop, 0, limit);89 lastNative = el.scrollTop;90 const s = stateRef.current;91 s.scroll = scroll;92 s.limit = limit;93 s.progress = limit > 0 ? scroll / limit : 0;94 s.velocity = velocity;95 callbackRef.current?.(s);96 };97 98 const tick = (time: number) => {99 const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;100 lastTime = time;101 const limit = getLimit();102 target = clamp(target, 0, limit);103 if (Math.abs(el.scrollTop - current) > 2) {104 // Something else moved the viewport (scrollbar drag, keyboard, touch): hand control back.105 current = target = el.scrollTop;106 raf = 0;107 lastTime = 0;108 emit(0);109 return;110 }111 const prev = current;112 const frames = dt / 16.667;113 current += (target - current) * (1 - Math.pow(1 - ease, frames));114 if (Math.abs(target - current) < 0.35) current = target;115 el.scrollTop = current;116 emit((current - prev) / frames);117 if (current === target) {118 raf = 0;119 lastTime = 0;120 emit(0);121 return;122 }123 raf = requestAnimationFrame(tick);124 };125 126 const onWheel = (event: WheelEvent) => {127 if (event.ctrlKey || event.defaultPrevented) return;128 if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;129 let dy = event.deltaY;130 if (event.deltaMode === 1) dy *= 16;131 else if (event.deltaMode === 2) dy *= el.clientHeight;132 dy *= multiplier;133 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;134 const limit = getLimit();135 if (!raf) current = target = el.scrollTop;136 // Release to native scrolling at the edges so the page is never trapped.137 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;138 event.preventDefault();139 target = clamp(target + dy, 0, limit);140 if (!raf) {141 lastTime = 0;142 raf = requestAnimationFrame(tick);143 }144 };145 146 const onNativeScroll = () => {147 if (raf) return;148 const top = el.scrollTop;149 const delta = top - lastNative;150 if (delta === 0) return;151 const now = performance.now();152 const dt = lastNativeTime ? clamp(now - lastNativeTime, 8, 100) : 16.667;153 lastNativeTime = now;154 current = target = top;155 emit(delta / (dt / 16.667));156 if (settleTimer) clearTimeout(settleTimer);157 settleTimer = setTimeout(() => {158 lastNativeTime = 0;159 emit(0);160 }, 90);161 };162 163 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });164 el.addEventListener("scroll", onNativeScroll, { passive: true });165 const ro = new ResizeObserver(() => emit(stateRef.current.velocity));166 ro.observe(el);167 if (el.firstElementChild) ro.observe(el.firstElementChild);168 emit(0);169 170 return () => {171 if (smooth) el.removeEventListener("wheel", onWheel);172 el.removeEventListener("scroll", onNativeScroll);173 ro.disconnect();174 if (raf) cancelAnimationFrame(raf);175 if (settleTimer) clearTimeout(settleTimer);176 };177 }, [containerRef, lerp, wheelMultiplier, smooth]);178 179 return stateRef;180}181 182export interface SmoothScrollProps {183 /** Scrollable content (defaults to a set of editorial demo sections) */184 children?: React.ReactNode;185 /** Viewport height in px */186 height?: number;187 /** Viewport corner radius in px */188 borderRadius?: number;189 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */190 lerp?: number;191 /** Multiplier applied to wheel / trackpad deltas */192 wheelMultiplier?: number;193 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */194 smooth?: boolean;195 /** Show a scroll progress bar along the top edge */196 showProgress?: boolean;197 /** Progress bar and accent color */198 progressColor?: string;199 /** Show a live velocity readout badge */200 showVelocity?: boolean;201 /** Accessible label for the scroll region */202 ariaLabel?: string;203 /** Called on every scroll frame with scroll, limit, progress (0..1) and velocity (px/frame) */204 onScroll?: (state: { scroll: number; limit: number; progress: number; velocity: number }) => void;205 /** Optional className merged onto the root */206 className?: string;207 /** Inline styles merged onto the root */208 style?: React.CSSProperties;209}210 211const SECTIONS = [212 {213 eyebrow: "01 / Inertia",214 title: "Scrolling that glides.",215 body: "Every wheel tick moves a target, not the page. Each frame the viewport eases a fraction of the remaining distance toward it, so motion starts crisp and settles softly.",216 },217 {218 eyebrow: "02 / Interpolation",219 title: "One number, endless feel.",220 body: "Lerp controls the personality. Small values float like a magazine on ice; larger values feel tight and precise. Velocity and progress fall out of the same loop for free.",221 },222 {223 eyebrow: "03 / Respect",224 title: "Native where it matters.",225 body: "Touch keeps its own physics, keyboard and scrollbar stay instant, and at the edges the wheel is released so the surrounding page carries on scrolling.",226 },227 {228 eyebrow: "04 / Restraint",229 title: "Idle when settled.",230 body: "The animation loop only runs while there is distance left to cover. Once the viewport arrives, nothing ticks until the next gesture.",231 },232];233 234export function SmoothScroll({235 children,236 height = 380,237 borderRadius = 20,238 lerp = 0.1,239 wheelMultiplier = 1,240 smooth = true,241 showProgress = true,242 progressColor = "#a78bfa",243 showVelocity = true,244 ariaLabel = "Smooth scrolling content",245 onScroll,246 className = "",247 style,248}: SmoothScrollProps) {249 const reduced = usePrefersReducedMotion();250 const rootRef = React.useRef<HTMLDivElement>(null);251 const viewportRef = React.useRef<HTMLDivElement>(null);252 const readoutRef = React.useRef<HTMLSpanElement>(null);253 254 const handleScroll = React.useCallback(255 (state: SmoothScrollState) => {256 const root = rootRef.current;257 if (root) {258 root.style.setProperty("--mf-ss-p", state.progress.toFixed(4));259 root.style.setProperty("--mf-ss-v", Math.min(1, Math.abs(state.velocity) / 40).toFixed(3));260 }261 const readout = readoutRef.current;262 if (readout) {263 const v = Math.abs(state.velocity);264 const arrow = v < 0.05 ? "·" : state.velocity > 0 ? "↓" : "↑";265 readout.textContent = `${arrow} ${v.toFixed(1)} px/f`;266 }267 onScroll?.({ scroll: state.scroll, limit: state.limit, progress: state.progress, velocity: state.velocity });268 },269 [onScroll],270 );271 272 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });273 274 const vars = {275 "--mf-ss-h": `${height}px`,276 "--mf-ss-r": `${borderRadius}px`,277 "--mf-ss-accent": progressColor,278 ...style,279 } as React.CSSProperties;280 281 return (282 <div ref={rootRef} style={vars} className={`${styles.root} ${className}`.trim()}>283 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={ariaLabel}>284 <div className={styles.content}>285 {children ?? (286 <>287 <header className={styles.hero}>288 <span className={styles.kicker}>Smooth Scroll</span>289 <h3 className={styles.heroTitle}>Weightless, not floaty.</h3>290 <p className={styles.heroText}>Scroll inside this panel with a wheel or trackpad.</p>291 </header>292 {SECTIONS.map((section, i) => (293 <section key={section.title} className={styles.section}>294 <div className={styles.sectionText}>295 <span className={styles.eyebrow}>{section.eyebrow}</span>296 <h4 className={styles.sectionTitle}>{section.title}</h4>297 <p className={styles.sectionBody}>{section.body}</p>298 </div>299 <div300 aria-hidden="true"301 className={styles.art}302 style={{ "--mf-ss-i": i } as React.CSSProperties}303 />304 </section>305 ))}306 <footer className={styles.footer}>307 <span className={styles.footerLine} aria-hidden="true" />308 <p>End of the line. Keep scrolling to hand off to the page.</p>309 </footer>310 </>311 )}312 </div>313 </div>314 {showProgress && (315 <div aria-hidden="true" className={styles.progress}>316 <span className={styles.progressFill} />317 </div>318 )}319 {showVelocity && (320 <div aria-hidden="true" className={styles.badge}>321 <span className={styles.badgeLabel}>Velocity</span>322 <span ref={readoutRef} className={styles.badgeValue}>323 {"· 0.0 px/f"}324 </span>325 <span className={styles.meter}>326 <span className={styles.meterFill} />327 </span>328 </div>329 )}330 </div>331 );332}