Velocity Marquee
A continuously scrolling strip whose speed is driven by scroll velocity, with an exponential decay so it eases back to its resting pace rather than stopping dead. One rAF loop writing a single transform, so it stays on the compositor — the CSS keyframe version cannot respond to input at all, and the React-state version re-renders the subtree sixty times a second to move some text.
Scroll the page — it speeds up, and reverses if you scroll back.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { VelocityMarquee } from "@/components/velocitymarquee"; <VelocityMarquee> <span className="px-6 text-6xl">Design · Build · Ship ·</span></VelocityMarquee>Customise
0 makes it a plain constant marquee.
The component
components/craft/motion/VelocityMarquee.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * A marquee that reacts to scroll velocity — it speeds up as you scroll, and * flips direction when you scroll back. * * Built on one rAF loop writing a single transform, so the whole thing runs * on the compositor. The common alternative, a CSS keyframe animation, cannot * respond to input at all; the other common one, a React state update per * frame, re-renders the subtree sixty times a second to move a strip of text. * * Content is duplicated exactly once and the track is translated by half its * width, which is what makes the wrap seamless without measuring anything. * The duplicate is hidden from assistive technology so the text is announced * once rather than twice. */ export interface VelocityMarqueeProps { children: React.ReactNode; /** Pixels per second at rest. */ baseSpeed?: number; /** How hard scrolling pushes it. 0 makes it a plain constant marquee. */ velocityFactor?: number; direction?: "left" | "right"; /** Force the static state — reduced motion, print, screenshots. */ still?: boolean; className?: string;} export function VelocityMarquee({ children, baseSpeed = 60, velocityFactor = 4, direction = "left", still = false, className = "",}: VelocityMarqueeProps) { const trackRef = useRef<HTMLDivElement>(null); useEffect(() => { const track = trackRef.current; if (!track) return; const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); if (reduced) return; let offset = 0; let velocity = 0; let lastScroll = window.scrollY; let last = performance.now(); let frame = 0; const onScroll = () => { const now = window.scrollY; velocity += (now - lastScroll) * velocityFactor; lastScroll = now; }; const tick = (now: number) => { const delta = Math.min((now - last) / 1000, 0.05); last = now; // Scroll impulse decays exponentially, so the strip eases back to its // resting speed instead of stopping dead when scrolling does. velocity *= 0.92; const sign = direction === "left" ? -1 : 1; offset += (baseSpeed * sign + velocity) * delta; // Half the track is one full copy of the content, so wrapping there is // invisible. Modulo keeps the number small — left to accumulate, it // eventually loses float precision and the strip judders. const half = track.scrollWidth / 2; if (half > 0) offset = ((offset % half) + half) % half; track.style.transform = `translate3d(${-offset}px, 0, 0)`; frame = requestAnimationFrame(tick); }; window.addEventListener("scroll", onScroll, { passive: true }); frame = requestAnimationFrame(tick); return () => { window.removeEventListener("scroll", onScroll); cancelAnimationFrame(frame); }; }, [baseSpeed, velocityFactor, direction, still]); return ( <div className={`overflow-hidden ${className}`}> <div ref={trackRef} className="flex w-max will-change-transform"> <div className="flex shrink-0 items-center">{children}</div> {/* The seamless-wrap duplicate. Hidden from the a11y tree so the content is not announced twice. */} <div aria-hidden className="flex shrink-0 items-center"> {children} </div> </div> </div> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+1.2KB gzipped · no new dependencies
- Runs on
Compositor
Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.
- Reduced motion
Does not animate at all. The strip renders static at its start position and stays readable.
- Accessibility
- The duplicated copy needed for the seamless wrap is aria-hidden, so content is announced once.
- Moving text is exempted under reduced motion, which this honours.
- Adds no tab stops; links inside remain focusable and reachable.
- Watch out
- Content is duplicated in the DOM. Keep it to a strip — do not put a hundred nodes inside.
- The scroll listener is passive and cheap, but it is global; one per page is sensible, a dozen is not.