ScrollScroll Animationsno dependencies

VelocityMarquee

Two counter-direction marquee rows pinned inside a smooth-scroll viewport: they drift at a base speed, then accelerate, flip direction and skew with scroll velocity, easing back smoothly when scrolling stops.

Preview

Customize

10 props
1.2
0.5
64
0.1
1

Installation

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

npx motionfoundry add velocity-marquee

Writes components/ui/velocity-marquee.tsx and components/styles/velocity-marquee.module.css.

Usage

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

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

Props

PropTypeDefault
text

Phrases repeated along both marquee rows.

string[]['Smooth scroll', 'Built from scratch', 'Velocity aware', 'Motion Foundry']
baseSpeed

Idle drift speed in px per 60fps frame.

number1.2
velocityFactor

How strongly scroll velocity boosts the marquee speed.

number0.5
skew

Skew the rows in proportion to scroll velocity.

booleantrue
color

Text color.

string'#f4f4f6'
outline

Render the second row as outlined text.

booleantrue
fontSize

Font size in px; shrinks on narrow containers.

number64
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. The marquee rows are aria-hidden duplicates; the phrases are exposed once via the region's aria-label. The loop pauses off-screen (IntersectionObserver) and does not run at all under prefers-reduced-motion.

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

Source

components/ui/velocity-marquee.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/velocity-marquee.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 VelocityMarqueeProps {183  /** Phrases repeated along both marquee rows */184  text?: string[];185  /** Idle drift speed in px per 60fps frame */186  baseSpeed?: number;187  /** How strongly scroll velocity boosts the marquee speed */188  velocityFactor?: number;189  /** Skew the rows in proportion to scroll velocity */190  skew?: boolean;191  /** Text color */192  color?: string;193  /** Render the second row as outlined text */194  outline?: boolean;195  /** Font size in px (shrinks on narrow containers) */196  fontSize?: number;197  /** Viewport height in px */198  height?: number;199  /** Viewport corner radius in px */200  borderRadius?: number;201  /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */202  lerp?: number;203  /** Multiplier applied to wheel / trackpad deltas */204  wheelMultiplier?: number;205  /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */206  smooth?: boolean;207  /** Optional className merged onto the root */208  className?: string;209  /** Inline styles merged onto the root */210  style?: React.CSSProperties;211}212 213const DEFAULT_TEXT = ["Smooth scroll", "Built from scratch", "Velocity aware", "Motion Foundry"];214const COPIES = 4;215 216export function VelocityMarquee({217  text = DEFAULT_TEXT,218  baseSpeed = 1.2,219  velocityFactor = 0.5,220  skew = true,221  color = "#f4f4f6",222  outline = true,223  fontSize = 64,224  height = 380,225  borderRadius = 20,226  lerp = 0.1,227  wheelMultiplier = 1,228  smooth = true,229  className = "",230  style,231}: VelocityMarqueeProps) {232  const reduced = usePrefersReducedMotion();233  const rootRef = React.useRef<HTMLDivElement>(null);234  const viewportRef = React.useRef<HTMLDivElement>(null);235  const rowARef = React.useRef<HTMLDivElement>(null);236  const rowBRef = React.useRef<HTMLDivElement>(null);237  const velocityRef = React.useRef(0);238 239  const handleScroll = React.useCallback((state: SmoothScrollState) => {240    velocityRef.current = state.velocity;241  }, []);242 243  useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });244 245  React.useEffect(() => {246    const root = rootRef.current;247    const rowA = rowARef.current;248    const rowB = rowBRef.current;249    if (!root || !rowA || !rowB) return;250    if (reduced) {251      rowA.style.transform = "";252      rowB.style.transform = "";253      return;254    }255    let raf = 0;256    let last = 0;257    let visible = false;258    let smoothed = 0;259    let direction = 1;260    let posA = 0;261    let posB = 0;262    let periodA = rowA.scrollWidth / COPIES;263    let periodB = rowB.scrollWidth / COPIES;264 265    const wrap = (pos: number, period: number) => (period > 0 ? ((pos % period) + period) % period : 0);266 267    const frame = (time: number) => {268      const frames = last ? Math.min(4, (time - last) / 16.667) : 1;269      last = time;270      smoothed += (velocityRef.current - smoothed) * (1 - Math.pow(0.88, frames));271      if (smoothed > 0.25) direction = 1;272      else if (smoothed < -0.25) direction = -1;273      const speed = (Math.max(0, baseSpeed) + Math.abs(smoothed) * Math.max(0, velocityFactor)) * frames;274      posA += speed * direction;275      posB -= speed * direction;276      const skewDeg = skew ? clamp(smoothed * 0.7, -14, 14) : 0;277      rowA.style.transform = `translate3d(${(-wrap(posA, periodA)).toFixed(2)}px,0,0) skewX(${(-skewDeg).toFixed(2)}deg)`;278      rowB.style.transform = `translate3d(${(-wrap(posB, periodB)).toFixed(2)}px,0,0) skewX(${skewDeg.toFixed(2)}deg)`;279      root.style.setProperty("--mf-vm-v", Math.min(1, Math.abs(smoothed) / 30).toFixed(3));280      raf = requestAnimationFrame(frame);281    };282 283    const start = () => {284      if (!raf && visible) {285        last = 0;286        raf = requestAnimationFrame(frame);287      }288    };289    const stop = () => {290      if (raf) cancelAnimationFrame(raf);291      raf = 0;292    };293 294    const io = new IntersectionObserver((entries) => {295      visible = entries.some((entry) => entry.isIntersecting);296      if (visible) start();297      else stop();298    });299    io.observe(root);300    const ro = new ResizeObserver(() => {301      periodA = rowA.scrollWidth / COPIES;302      periodB = rowB.scrollWidth / COPIES;303    });304    ro.observe(rowA);305    ro.observe(rowB);306 307    return () => {308      stop();309      io.disconnect();310      ro.disconnect();311    };312  }, [reduced, baseSpeed, velocityFactor, skew]);313 314  const phrases = text.length > 0 ? text : DEFAULT_TEXT;315 316  const vars = {317    "--mf-vm-h": `${height}px`,318    "--mf-vm-r": `${borderRadius}px`,319    "--mf-vm-color": color,320    "--mf-vm-fs": `${fontSize}px`,321    ...style,322  } as React.CSSProperties;323 324  const renderRow = (rowKey: string) =>325    Array.from({ length: COPIES }, (_, copy) => (326      <span key={`${rowKey}-${copy}`} className={styles.group}>327        {phrases.map((phrase, i) => (328          <React.Fragment key={i}>329            <span className={styles.phrase}>{phrase}</span>330            <span className={styles.sep} />331          </React.Fragment>332        ))}333      </span>334    ));335 336  return (337    <div ref={rootRef} style={vars} className={`${styles.root} ${className}`.trim()}>338      <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={phrases.join(", ")}>339        <div className={styles.track} style={{ height: `${Math.round(height * 4)}px` }}>340          <div className={styles.stage}>341            <span aria-hidden="true" className={styles.hint}>342              Scroll faster343              <span className={styles.meter}>344                <span className={styles.meterFill} />345              </span>346            </span>347            <div aria-hidden="true" className={styles.rows}>348              <div className={styles.row}>349                <div ref={rowARef} className={styles.strip}>350                  {renderRow("a")}351                </div>352              </div>353              <div className={`${styles.row} ${outline ? styles.outlined : ""}`.trim()}>354                <div ref={rowBRef} className={styles.strip}>355                  {renderRow("b")}356                </div>357              </div>358            </div>359          </div>360        </div>361      </div>362    </div>363  );364}