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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add velocity-marqueeWrites 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.
1import { VelocityMarquee } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <VelocityMarquee />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 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
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}