Velocity marquee
A marquee whose speed and direction follow scroll velocity, smoothed per frame.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/velocity-marquee.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/velocity-marquee
Two rows in a pinned stage, drifting apart. Scroll the frame to drive them.
Usage
import { VelocityMarquee } from "@/components/ui/velocity-marquee"
<VelocityMarquee aria-label="Details we sweat" className="text-7xl font-medium">
<span className="whitespace-nowrap">Hairlines</span>
<span className="whitespace-nowrap">Focus rings</span>
</VelocityMarquee>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| speed | number | 48 | Resting drift in pixels per second. |
| direction | 1 | -1 | 1 | 1 drifts left on the way down the page, -1 drifts right. Scrolling up reverses either. |
| gain | number | 0.35 | Extra pixels per second of drift for each pixel per second of scroll. |
| smoothing | number | 6 | Rate k in 1 - e^(-k dt). Higher follows the scroll more tightly. |
| gap | number | 48 | Space between items in pixels, repeated at the seam. |
Source
velocity-marquee/velocity-marquee.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP);
const MOTION = "(prefers-reduced-motion: no-preference)";
const POINTER = "(hover: hover) and (pointer: fine)";
type VelocityMarqueeProps = React.ComponentProps<"div"> & {
/** Resting drift in pixels per second. */
speed?: number;
/** 1 drifts left on the way down the page, -1 drifts right. Scrolling up reverses either. */
direction?: 1 | -1;
/** Extra pixels per second of drift for each pixel per second of scroll. */
gain?: number;
/** Rate k in 1 - e^(-k dt). Higher follows the scroll more tightly. */
smoothing?: number;
/** Space between items in pixels, repeated at the seam. */
gap?: number;
};
/**
* A marquee whose speed and direction follow scroll velocity, smoothed per frame so it behaves the same
* at any refresh rate. Hover and keyboard focus stop the drift, and the scroll term still moves it,
* because that motion belongs to the person scrolling. Under reduced motion it is a static, wrapped row.
*/
function VelocityMarquee({
speed = 48,
direction = 1,
gain = 0.35,
smoothing = 6,
gap = 48,
className,
children,
...props
}: VelocityMarqueeProps) {
const root = React.useRef<HTMLDivElement>(null);
const [copies, setCopies] = React.useState(3);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(MOTION, () => {
const element = root.current;
const track = element?.querySelector<HTMLElement>("[data-slot=velocity-marquee-track]");
const set = element?.querySelector<HTMLElement>("[data-slot=velocity-marquee-set]");
if (!element || !track || !set) return;
let width = 0;
let x = 0;
let velocity = 0;
let heading = 1;
let lastY = window.scrollY;
let hovered = false;
let focused = false;
const measure = () => {
width = set.getBoundingClientRect().width;
if (width) setCopies(Math.min(Math.ceil(element.clientWidth / width) + 1, 12));
};
const tick = (_: number, deltaMs: number) => {
if (!width || !deltaMs) return;
const y = window.scrollY;
const scroll = (y - lastY) / (deltaMs / 1000);
lastY = y;
if (Math.abs(scroll) > 1) heading = Math.sign(scroll);
const dt = Math.min(deltaMs / 1000, 0.05);
const target = (hovered || focused ? 0 : heading * speed) + scroll * gain;
velocity += (target - velocity) * (1 - Math.exp(-smoothing * dt));
// Past the end of one set the next is identical, so wrapping by its width is invisible.
x = gsap.utils.wrap(-width, 0, x - velocity * direction * dt);
track.style.transform = `translate3d(${x}px, 0, 0)`;
};
// The loop only runs while the row is on screen.
const watcher = new IntersectionObserver((entries) => {
gsap.ticker.remove(tick);
if (!entries[entries.length - 1].isIntersecting) return;
lastY = window.scrollY;
gsap.ticker.add(tick);
});
watcher.observe(element);
const resizer = new ResizeObserver(measure);
resizer.observe(element);
resizer.observe(set);
void document.fonts.ready.then(measure);
const pointer = window.matchMedia(POINTER).matches;
const onEnter = () => {
hovered = true;
};
const onLeave = () => {
hovered = false;
};
const onFocusIn = (e: FocusEvent) => {
focused = (e.target as HTMLElement).matches(":focus-visible");
};
const onFocusOut = () => {
focused = false;
};
if (pointer) {
element.addEventListener("pointerenter", onEnter);
element.addEventListener("pointerleave", onLeave);
}
element.addEventListener("focusin", onFocusIn);
element.addEventListener("focusout", onFocusOut);
return () => {
gsap.ticker.remove(tick);
watcher.disconnect();
resizer.disconnect();
element.removeEventListener("pointerenter", onEnter);
element.removeEventListener("pointerleave", onLeave);
element.removeEventListener("focusin", onFocusIn);
element.removeEventListener("focusout", onFocusOut);
track.style.transform = "";
};
});
return () => media.revert();
},
{ scope: root, dependencies: [speed, direction, gain, smoothing] },
);
const row = { gap, paddingRight: gap };
return (
<div
ref={root}
data-slot="velocity-marquee"
role="group"
tabIndex={0}
className={cn("-my-[0.16em] overflow-clip py-[0.16em] focus-visible:-outline-offset-2", className)}
{...props}
>
<div data-slot="velocity-marquee-track" className="flex w-max motion-reduce:w-auto">
<div
data-slot="velocity-marquee-set"
style={row}
className="flex shrink-0 items-center motion-reduce:shrink motion-reduce:flex-wrap motion-reduce:justify-center motion-reduce:pr-0!"
>
{children}
</div>
{Array.from({ length: copies - 1 }, (_, i) => (
<div key={i} aria-hidden="true" inert style={row} className="flex shrink-0 items-center motion-reduce:hidden">
{children}
</div>
))}
</div>
</div>
);
}
export { VelocityMarquee };
For AI agents
Hand your agent this page, the markdown at /components/velocity-marquee/prompt.md, or the Copy AI prompt button above.
When to use
- A big type band between two sections of a landing page
- A row of customer names or tools, where order does not matter
- A page with enough scroll for the velocity to be felt
When not to use
- Content people have to read in full
- Short pages with nothing to scroll
- Inside a nested scroller: it reads the window's scroll
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- Give it an aria-label. It is a focusable group, so a keyboard user can stop the drift. Clones are aria-hidden and inert
- Hover and focus stop the resting drift. Scrolling still moves it, because that motion is the person's own and a full stop would freeze it under a resting pointer
- Under reduced motion the clones go and the first set wraps as a centred static row, so nothing is cut off
- Scroll velocity comes from window.scrollY each frame, so it needs the window as the scroller. The loop stops while the row is off screen
The maths
- Velocity eases by v += (target - v) × (1 - e^(-k dt)), so k = 6 covers 63 percent of the gap in 167ms at any refresh rate.
- Target is heading × speed + scroll × gain, so 2000px/s of scroll adds 700px/s at the default gain.
- Copies are ceil(W / set) + 1 and x wraps by one set's width, so the seam never shows.