Skip to content
Motion

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.

Loading preview…

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

60px/s
4

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.