Velocity Marquee
MarketingAn infinite strip whose speed and skew couple to scroll velocity — it accelerates as you scroll, reverses when you scroll back, and eases to a base drift when you stop. The wrap is a modulo on a measured content width (seamless in both directions, no CSS-animation restart), and the velocity is smoothed by an exponential moving average so it coasts instead of snapping.
Scroll me — fast, slow, and backwards
01 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
02 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
03 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
04 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
05 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
06 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
07 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
08 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
09 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
10 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
11 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
12 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
13 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
14 Flick down and the strip accelerates left and shears with it. Flick up hard enough and it crosses zero and runs the other way. Let go and it coasts back to the base drift instead of stopping dead — that easing is the whole point.
Drop decay to 0.05s and the strip snaps to a halt the instant you stop — that is exactly the broken-feeling version. 0.35s is the default.
"use client";
import * as React from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useReducedMotion,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* VelocityMarquee — an infinite strip whose speed and skew couple to scroll.
*
* It drifts at a constant base rate, accelerates while you scroll, reverses
* when you scroll back past the base rate, and *eases* back to the base drift
* when you stop.
*
* ## The seam
*
* The wrap is a modulo on an accumulated offset, not a restarted CSS
* animation — a restart re-interpolates from a keyframe boundary and visibly
* hitches at the seam. Here:
*
* offset += speed * dt
* offset = ((offset % unit) + unit) % unit // always in [0, unit)
* x = -(offset + unit) // one copy of lead-in
*
* `unit` is the measured width of one copy of the children. Because the strip
* repeats every `unit` pixels, `x = -unit` and `x = -2·unit` are visually
* identical, so the wrap is invisible in *both* directions. The `+ unit`
* lead-in is what makes reverse work: without it, scrolling up would slide the
* track right and expose blank space at the left edge.
*
* Copy count is derived, never hardcoded — `ceil(container / unit) + 2`, where
* the `+2` covers the lead-in copy plus the one being wrapped, and the
* container width is inflated by the horizontal shear the skew introduces
* (`tan(maxSkew) · height / 2` at each edge). A hardcoded ×2 breaks on wide
* viewports and on short content.
*
* Both measurements come from `ResizeObserver`'s `borderBoxSize`, which reports
* *layout* size — `getBoundingClientRect()` would return the skewed box and
* corrupt `unit`, and `offsetWidth` would round it to an integer and leave a
* permanent sub-pixel seam.
*
* ## The decay
*
* Scroll velocity is derived here rather than read from a helper — it's a
* position delta over a frame delta, then fed through an exponential moving
* average:
*
* k = exp(-dt / decay)
* boost = boost·k + raw·(1 - k)
*
* One line that does both jobs. It smooths the (noisy, frame-quantised) raw
* signal, and when `raw` drops to 0 it relaxes `boost` to 0 with a time
* constant of `decay` seconds rather than snapping. Snapping is what makes a
* hand-rolled version read as broken the instant you stop scrolling.
*
* `dt` is clamped to 64ms so a backgrounded tab or a long task can't inject one
* enormous delta and teleport the strip.
*
* Everything above lives in refs and motion values written straight to the DOM.
* A `useState` offset would re-render every child, every frame.
*/
/** Scroll speed (px/s) at which the skew reaches `maxSkew`. */
const SKEW_REFERENCE = 1400;
/** Frame delta ceiling (ms) — guards tab-switch and long-task spikes. */
const MAX_FRAME_MS = 64;
const clamp = (n: number, min: number, max: number) =>
n < min ? min : n > max ? max : n;
export interface VelocityMarqueeProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
children: React.ReactNode;
/**
* Resting drift in px/s. Positive drifts left, negative drifts right.
*/
baseVelocity?: number;
/** How strongly scroll velocity adds to the drift. 0 disables the coupling. */
velocityFactor?: number;
/**
* Time constant of the exponential decay, in seconds. Larger = the strip
* coasts longer after you stop scrolling. This is the feel of the component.
*/
decay?: number;
/** Peak skew in degrees at `±1400 px/s` of scroll. 0 disables the skew. */
maxSkew?: number;
/**
* Element whose `scrollTop` drives the effect. Defaults to the window.
*/
scrollRef?: React.RefObject<HTMLElement | null>;
/** Freeze the strip. Also satisfies WCAG 2.2.2 if you surface a control. */
paused?: boolean;
}
export function VelocityMarquee({
children,
baseVelocity = 40,
velocityFactor = 1.2,
decay = 0.35,
maxSkew = 6,
scrollRef,
paused = false,
className,
...props
}: VelocityMarqueeProps) {
const reduce = useReducedMotion();
const containerRef = React.useRef<HTMLDivElement>(null);
const copyRef = React.useRef<HTMLDivElement>(null);
const [copies, setCopies] = React.useState(3);
// Measured layout, kept off the render path — the frame loop reads these.
const unitRef = React.useRef(0);
const containerWRef = React.useRef(0);
const containerHRef = React.useRef(0);
// Animation state. Refs, not state: this changes 60 times a second.
const offsetRef = React.useRef(0);
const boostRef = React.useRef(0);
const lastScrollRef = React.useRef(0);
const seedRef = React.useRef(true);
const visibleRef = React.useRef(true);
const x = useMotionValue(0);
const skewX = useMotionValue(0);
// --- measure -------------------------------------------------------------
React.useEffect(() => {
const container = containerRef.current;
const copy = copyRef.current;
if (!container || !copy) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.borderBoxSize?.[0];
const inline =
box?.inlineSize ?? (entry.target as HTMLElement).offsetWidth;
if (entry.target === copy) {
unitRef.current = inline;
} else {
containerWRef.current = inline;
containerHRef.current =
box?.blockSize ?? (entry.target as HTMLElement).offsetHeight;
}
}
const unit = unitRef.current;
const width = containerWRef.current;
if (unit <= 0 || width <= 0) return;
// Skewing the track shears it horizontally by tan(θ)·h/2 at each edge;
// widen the coverage requirement so the shear can never expose a seam.
const shear =
Math.abs(Math.tan((maxSkew * Math.PI) / 180)) *
(containerHRef.current / 2);
setCopies(Math.max(3, Math.ceil((width + shear * 2) / unit) + 2));
// Re-derive the resting position from the new `unit`, so the first paint
// already sits on the lead-in copy (no one-frame jump on mount) and a
// resize doesn't leave the track parked at a stale offset.
x.set(-(offsetRef.current + unit));
});
ro.observe(container);
ro.observe(copy);
return () => ro.disconnect();
// `children` is deliberately not a dep: if the content's width changes the
// observer on copy 0 already fires, and adding it would tear the observer
// down on every render that passes inline JSX.
}, [maxSkew, x]);
// --- pause when offscreen ------------------------------------------------
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const io = new IntersectionObserver((entries) => {
visibleRef.current = entries[0]?.isIntersecting ?? true;
// Don't integrate the scroll that happened while we weren't looking.
if (!visibleRef.current) seedRef.current = true;
});
io.observe(container);
return () => io.disconnect();
}, []);
const readScroll = React.useCallback(() => {
const el = scrollRef?.current;
return el ? el.scrollTop : window.scrollY;
}, [scrollRef]);
// --- the loop ------------------------------------------------------------
useAnimationFrame((_, delta) => {
const unit = unitRef.current;
// Reduced motion stops the drift entirely — not "slower", stopped.
if (reduce) {
offsetRef.current = 0;
boostRef.current = 0;
x.set(-unit);
skewX.set(0);
return;
}
if (paused || !visibleRef.current || document.hidden || unit <= 0) {
// Re-seed so the gap doesn't arrive as one enormous velocity spike.
seedRef.current = true;
return;
}
const dt = Math.min(delta, MAX_FRAME_MS) / 1000;
const scroll = readScroll();
if (seedRef.current) {
lastScrollRef.current = scroll;
seedRef.current = false;
return;
}
// Raw scroll velocity, px/s, straight from the position delta.
const raw = (scroll - lastScrollRef.current) / dt;
lastScrollRef.current = scroll;
// Exponential moving average: smooths the raw signal on the way in, and
// relaxes to 0 with time constant `decay` once scrolling stops.
const k = Math.exp(-dt / decay);
boostRef.current = boostRef.current * k + raw * (1 - k);
// Scroll back hard enough and this goes negative — the strip reverses.
const speed = baseVelocity + boostRef.current * velocityFactor;
const next = offsetRef.current + speed * dt;
// Two-step modulo: JS's % keeps the sign, so a negative speed would walk
// the offset out of range and break the wrap.
offsetRef.current = ((next % unit) + unit) % unit;
x.set(-(offsetRef.current + unit));
skewX.set(clamp(boostRef.current / SKEW_REFERENCE, -1, 1) * maxSkew);
});
return (
<div
ref={containerRef}
data-slot="velocity-marquee"
className={cn("relative w-full overflow-hidden", className)}
{...props}
>
<motion.div
data-slot="velocity-marquee-track"
className="flex w-max will-change-transform"
style={{ x, skewX }}
>
{Array.from({ length: copies }, (_, i) => (
<div
key={i}
ref={i === 0 ? copyRef : undefined}
data-slot="velocity-marquee-copy"
// Only the first copy is real content. The rest are hidden from
// the accessibility tree *and* made inert, so a screen reader
// never repeats the strip and Tab never lands in a clone.
aria-hidden={i > 0 || undefined}
inert={i > 0}
className="flex w-max shrink-0 items-center"
>
{children}
</div>
))}
</motion.div>
</div>
);
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/velocity-marquee.json1. Install dependencies
npm install motion clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useReducedMotion,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* VelocityMarquee — an infinite strip whose speed and skew couple to scroll.
*
* It drifts at a constant base rate, accelerates while you scroll, reverses
* when you scroll back past the base rate, and *eases* back to the base drift
* when you stop.
*
* ## The seam
*
* The wrap is a modulo on an accumulated offset, not a restarted CSS
* animation — a restart re-interpolates from a keyframe boundary and visibly
* hitches at the seam. Here:
*
* offset += speed * dt
* offset = ((offset % unit) + unit) % unit // always in [0, unit)
* x = -(offset + unit) // one copy of lead-in
*
* `unit` is the measured width of one copy of the children. Because the strip
* repeats every `unit` pixels, `x = -unit` and `x = -2·unit` are visually
* identical, so the wrap is invisible in *both* directions. The `+ unit`
* lead-in is what makes reverse work: without it, scrolling up would slide the
* track right and expose blank space at the left edge.
*
* Copy count is derived, never hardcoded — `ceil(container / unit) + 2`, where
* the `+2` covers the lead-in copy plus the one being wrapped, and the
* container width is inflated by the horizontal shear the skew introduces
* (`tan(maxSkew) · height / 2` at each edge). A hardcoded ×2 breaks on wide
* viewports and on short content.
*
* Both measurements come from `ResizeObserver`'s `borderBoxSize`, which reports
* *layout* size — `getBoundingClientRect()` would return the skewed box and
* corrupt `unit`, and `offsetWidth` would round it to an integer and leave a
* permanent sub-pixel seam.
*
* ## The decay
*
* Scroll velocity is derived here rather than read from a helper — it's a
* position delta over a frame delta, then fed through an exponential moving
* average:
*
* k = exp(-dt / decay)
* boost = boost·k + raw·(1 - k)
*
* One line that does both jobs. It smooths the (noisy, frame-quantised) raw
* signal, and when `raw` drops to 0 it relaxes `boost` to 0 with a time
* constant of `decay` seconds rather than snapping. Snapping is what makes a
* hand-rolled version read as broken the instant you stop scrolling.
*
* `dt` is clamped to 64ms so a backgrounded tab or a long task can't inject one
* enormous delta and teleport the strip.
*
* Everything above lives in refs and motion values written straight to the DOM.
* A `useState` offset would re-render every child, every frame.
*/
/** Scroll speed (px/s) at which the skew reaches `maxSkew`. */
const SKEW_REFERENCE = 1400;
/** Frame delta ceiling (ms) — guards tab-switch and long-task spikes. */
const MAX_FRAME_MS = 64;
const clamp = (n: number, min: number, max: number) =>
n < min ? min : n > max ? max : n;
export interface VelocityMarqueeProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
children: React.ReactNode;
/**
* Resting drift in px/s. Positive drifts left, negative drifts right.
*/
baseVelocity?: number;
/** How strongly scroll velocity adds to the drift. 0 disables the coupling. */
velocityFactor?: number;
/**
* Time constant of the exponential decay, in seconds. Larger = the strip
* coasts longer after you stop scrolling. This is the feel of the component.
*/
decay?: number;
/** Peak skew in degrees at `±1400 px/s` of scroll. 0 disables the skew. */
maxSkew?: number;
/**
* Element whose `scrollTop` drives the effect. Defaults to the window.
*/
scrollRef?: React.RefObject<HTMLElement | null>;
/** Freeze the strip. Also satisfies WCAG 2.2.2 if you surface a control. */
paused?: boolean;
}
export function VelocityMarquee({
children,
baseVelocity = 40,
velocityFactor = 1.2,
decay = 0.35,
maxSkew = 6,
scrollRef,
paused = false,
className,
...props
}: VelocityMarqueeProps) {
const reduce = useReducedMotion();
const containerRef = React.useRef<HTMLDivElement>(null);
const copyRef = React.useRef<HTMLDivElement>(null);
const [copies, setCopies] = React.useState(3);
// Measured layout, kept off the render path — the frame loop reads these.
const unitRef = React.useRef(0);
const containerWRef = React.useRef(0);
const containerHRef = React.useRef(0);
// Animation state. Refs, not state: this changes 60 times a second.
const offsetRef = React.useRef(0);
const boostRef = React.useRef(0);
const lastScrollRef = React.useRef(0);
const seedRef = React.useRef(true);
const visibleRef = React.useRef(true);
const x = useMotionValue(0);
const skewX = useMotionValue(0);
// --- measure -------------------------------------------------------------
React.useEffect(() => {
const container = containerRef.current;
const copy = copyRef.current;
if (!container || !copy) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.borderBoxSize?.[0];
const inline =
box?.inlineSize ?? (entry.target as HTMLElement).offsetWidth;
if (entry.target === copy) {
unitRef.current = inline;
} else {
containerWRef.current = inline;
containerHRef.current =
box?.blockSize ?? (entry.target as HTMLElement).offsetHeight;
}
}
const unit = unitRef.current;
const width = containerWRef.current;
if (unit <= 0 || width <= 0) return;
// Skewing the track shears it horizontally by tan(θ)·h/2 at each edge;
// widen the coverage requirement so the shear can never expose a seam.
const shear =
Math.abs(Math.tan((maxSkew * Math.PI) / 180)) *
(containerHRef.current / 2);
setCopies(Math.max(3, Math.ceil((width + shear * 2) / unit) + 2));
// Re-derive the resting position from the new `unit`, so the first paint
// already sits on the lead-in copy (no one-frame jump on mount) and a
// resize doesn't leave the track parked at a stale offset.
x.set(-(offsetRef.current + unit));
});
ro.observe(container);
ro.observe(copy);
return () => ro.disconnect();
// `children` is deliberately not a dep: if the content's width changes the
// observer on copy 0 already fires, and adding it would tear the observer
// down on every render that passes inline JSX.
}, [maxSkew, x]);
// --- pause when offscreen ------------------------------------------------
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const io = new IntersectionObserver((entries) => {
visibleRef.current = entries[0]?.isIntersecting ?? true;
// Don't integrate the scroll that happened while we weren't looking.
if (!visibleRef.current) seedRef.current = true;
});
io.observe(container);
return () => io.disconnect();
}, []);
const readScroll = React.useCallback(() => {
const el = scrollRef?.current;
return el ? el.scrollTop : window.scrollY;
}, [scrollRef]);
// --- the loop ------------------------------------------------------------
useAnimationFrame((_, delta) => {
const unit = unitRef.current;
// Reduced motion stops the drift entirely — not "slower", stopped.
if (reduce) {
offsetRef.current = 0;
boostRef.current = 0;
x.set(-unit);
skewX.set(0);
return;
}
if (paused || !visibleRef.current || document.hidden || unit <= 0) {
// Re-seed so the gap doesn't arrive as one enormous velocity spike.
seedRef.current = true;
return;
}
const dt = Math.min(delta, MAX_FRAME_MS) / 1000;
const scroll = readScroll();
if (seedRef.current) {
lastScrollRef.current = scroll;
seedRef.current = false;
return;
}
// Raw scroll velocity, px/s, straight from the position delta.
const raw = (scroll - lastScrollRef.current) / dt;
lastScrollRef.current = scroll;
// Exponential moving average: smooths the raw signal on the way in, and
// relaxes to 0 with time constant `decay` once scrolling stops.
const k = Math.exp(-dt / decay);
boostRef.current = boostRef.current * k + raw * (1 - k);
// Scroll back hard enough and this goes negative — the strip reverses.
const speed = baseVelocity + boostRef.current * velocityFactor;
const next = offsetRef.current + speed * dt;
// Two-step modulo: JS's % keeps the sign, so a negative speed would walk
// the offset out of range and break the wrap.
offsetRef.current = ((next % unit) + unit) % unit;
x.set(-(offsetRef.current + unit));
skewX.set(clamp(boostRef.current / SKEW_REFERENCE, -1, 1) * maxSkew);
});
return (
<div
ref={containerRef}
data-slot="velocity-marquee"
className={cn("relative w-full overflow-hidden", className)}
{...props}
>
<motion.div
data-slot="velocity-marquee-track"
className="flex w-max will-change-transform"
style={{ x, skewX }}
>
{Array.from({ length: copies }, (_, i) => (
<div
key={i}
ref={i === 0 ? copyRef : undefined}
data-slot="velocity-marquee-copy"
// Only the first copy is real content. The rest are hidden from
// the accessibility tree *and* made inert, so a screen reader
// never repeats the strip and Tab never lands in a clone.
aria-hidden={i > 0 || undefined}
inert={i > 0}
className="flex w-max shrink-0 items-center"
>
{children}
</div>
))}
</motion.div>
</div>
);
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | One copy of the strip's content. It is duplicated at runtime until it covers the container — the count is derived from a measured width, never hardcoded. Only the first copy is in the accessibility tree; the rest are `aria-hidden` and `inert`. |
baseVelocity | number | 40 | Resting drift in px/s. Positive drifts left, negative drifts right. |
velocityFactor | number | 1.2 | How strongly scroll velocity adds to the drift. Set to 0 for a plain constant marquee. |
decay | number | 0.35 | Time constant of the exponential decay, in seconds — how long the strip coasts after you stop scrolling. This is the feel of the component: at 0.05 it snaps to a halt and reads as broken. |
maxSkew | number | 6 | Peak skew in degrees, reached at ±1400 px/s of scroll. The copy count compensates for the horizontal shear, so raising this never exposes a seam. 0 disables it. |
scrollRef | React.RefObject<HTMLElement | null> | — | Element whose `scrollTop` drives the effect. Defaults to the window. |
paused | boolean | false | Freeze the strip in place. Surface it as a control to satisfy WCAG 2.2.2; `prefers-reduced-motion` already stops it entirely. |