ScrollScroll Animationsno dependencies

HorizontalScroll

Vertical wheel or touch scrolling inside a smooth-scroll viewport drives a pinned horizontal track of cards; the scroll distance is measured from the track width so the last card lands exactly at the end, with a counter and progress line.

Preview

Scroll down, travel sideways

  • 01Discover
  • 02Sketch
  • 03Prototype
  • 04Refine
  • 05Animate
  • 06Ship

Customize

8 props
260
20
0.1
1

Installation

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

npx motionfoundry add horizontal-scroll

Writes components/ui/horizontal-scroll.tsx and components/styles/horizontal-scroll.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

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

Props

PropTypeDefault
items

Card titles, one card per item.

string[]['Discover', 'Sketch', 'Prototype', 'Refine', 'Animate', 'Ship']
title

Heading shown above the track.

string'Scroll down, travel sideways'
cardWidth

Card width in px.

number260
gap

Gap between cards in px.

number20
accentColor

Accent color for card art, counter and progress line.

string'#38bdf8'
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
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. Cards are a semantic list; the counter, progress line and card art are aria-hidden.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/horizontal-scroll.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/horizontal-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 HorizontalScrollProps {183  /** Card titles, one card per item */184  items?: string[];185  /** Heading shown above the track */186  title?: string;187  /** Card width in px */188  cardWidth?: number;189  /** Gap between cards in px */190  gap?: number;191  /** Accent color for card art, counter and progress */192  accentColor?: string;193  /** Viewport height in px */194  height?: number;195  /** Viewport corner radius in px */196  borderRadius?: number;197  /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */198  lerp?: number;199  /** Multiplier applied to wheel / trackpad deltas */200  wheelMultiplier?: number;201  /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */202  smooth?: boolean;203  /** Optional className merged onto the root */204  className?: string;205  /** Inline styles merged onto the root */206  style?: React.CSSProperties;207}208 209const DEFAULT_ITEMS = ["Discover", "Sketch", "Prototype", "Refine", "Animate", "Ship"];210 211export function HorizontalScroll({212  items = DEFAULT_ITEMS,213  title = "Scroll down, travel sideways",214  cardWidth = 260,215  gap = 20,216  accentColor = "#38bdf8",217  height = 380,218  borderRadius = 20,219  lerp = 0.1,220  wheelMultiplier = 1,221  smooth = true,222  className = "",223  style,224}: HorizontalScrollProps) {225  const reduced = usePrefersReducedMotion();226  const rootRef = React.useRef<HTMLDivElement>(null);227  const viewportRef = React.useRef<HTMLDivElement>(null);228  const spacerRef = React.useRef<HTMLDivElement>(null);229  const stageRef = React.useRef<HTMLDivElement>(null);230  const trackRef = React.useRef<HTMLUListElement>(null);231  const counterRef = React.useRef<HTMLSpanElement>(null);232  const travelRef = React.useRef(0);233  const progressRef = React.useRef(0);234  const total = items.length;235 236  const apply = React.useCallback(() => {237    const root = rootRef.current;238    if (!root) return;239    const p = progressRef.current;240    root.style.setProperty("--mf-hs-x", `${(-p * travelRef.current).toFixed(2)}px`);241    root.style.setProperty("--mf-hs-p", p.toFixed(4));242    const counter = counterRef.current;243    if (counter && total > 0) {244      const index = Math.min(total, Math.floor(p * total) + 1);245      counter.textContent = `${String(index).padStart(2, "0")} / ${String(total).padStart(2, "0")}`;246    }247  }, [total]);248 249  const handleScroll = React.useCallback(250    (state: SmoothScrollState) => {251      progressRef.current = state.progress;252      apply();253    },254    [apply],255  );256 257  useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });258 259  React.useEffect(() => {260    const stage = stageRef.current;261    const track = trackRef.current;262    const spacer = spacerRef.current;263    if (!stage || !track || !spacer) return;264    const measure = () => {265      const travel = Math.max(0, track.scrollWidth - stage.clientWidth);266      travelRef.current = travel;267      spacer.style.height = `${Math.round(height + travel)}px`;268      apply();269    };270    measure();271    const ro = new ResizeObserver(measure);272    ro.observe(stage);273    ro.observe(track);274    return () => ro.disconnect();275  }, [height, cardWidth, gap, total, apply]);276 277  const vars = {278    "--mf-hs-h": `${height}px`,279    "--mf-hs-r": `${borderRadius}px`,280    "--mf-hs-accent": accentColor,281    "--mf-hs-card": `${cardWidth}px`,282    "--mf-hs-gap": `${gap}px`,283    ...style,284  } as React.CSSProperties;285 286  return (287    <div ref={rootRef} style={vars} className={`${styles.root} ${className}`.trim()}>288      <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>289        <div ref={spacerRef} className={styles.spacer} style={{ height: `${Math.round(height * 2.6)}px` }}>290          <div ref={stageRef} className={styles.stage}>291            <div className={styles.header}>292              <h3 className={styles.title}>{title}</h3>293              <span ref={counterRef} aria-hidden="true" className={styles.counter}>294                {`01 / ${String(total).padStart(2, "0")}`}295              </span>296            </div>297            <ul ref={trackRef} className={styles.track}>298              {items.map((item, i) => (299                <li key={`${item}-${i}`} className={styles.card} style={{ "--mf-hs-i": i } as React.CSSProperties}>300                  <span aria-hidden="true" className={styles.art} />301                  <span className={styles.index}>{String(i + 1).padStart(2, "0")}</span>302                  <span className={styles.cardTitle}>{item}</span>303                </li>304              ))}305            </ul>306            <div aria-hidden="true" className={styles.progress}>307              <span className={styles.progressFill} />308            </div>309          </div>310        </div>311      </div>312    </div>313  );314}