ScrollScroll Animationsno dependencies

SmoothScroll

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

Smooth Scroll

Weightless, not floaty.

Scroll inside this panel with a wheel or trackpad.

01 / Inertia

Scrolling that glides.

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.

02 / Interpolation

One number, endless feel.

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.

03 / Respect

Native where it matters.

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.

04 / Restraint

Idle when settled.

The animation loop only runs while there is distance left to cover. Once the viewport arrives, nothing ticks until the next gesture.

End of the line. Keep scrolling to hand off to the page.

Customize

7 props
0.1
1
380

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add smooth-scroll

Writes 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.

example.tsx
1import { SmoothScroll } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <SmoothScroll />6  );7}

Props

PropTypeDefault
children

Scrollable content; defaults to a hero plus four editorial sections.

React.ReactNodeundefined
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
showProgress

Show a scroll progress bar along the top edge.

booleantrue
progressColor

Progress bar and accent color.

string'#a78bfa'
showVelocity

Show a live velocity readout badge (px per frame).

booleantrue
ariaLabel

Accessible label for the scroll region.

string'Smooth scrolling content'
onScroll

Called every scroll frame with scroll position, limit, progress (0..1) and signed velocity.

(state: { scroll: number; limit: number; progress: number; velocity: number }) => voidundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/smooth-scroll.tsx
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}