Velocity Marquee
A marquee that speeds up, leans and turns around with your scroll.
Customize
<VelocityMarquee items={items} />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useLayoutEffect, useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";
// An endless line of big words and small pictures that drifts on its own. Scroll the page and it
// speeds up and leans into the movement; scroll back up and it turns around. With more than one
// row, every row runs against its neighbour.
//
// How the loop stays seamless: one "set" is the whole list of items. We measure it, then render
// as many copies as it takes to fill the box (plus one spare). Sliding the track by exactly one
// set's width lands on a picture identical to where it started, so we wrap with a modulo and
// nobody sees the jump. Every item carries its gap as padding, so a set's width includes the
// gap after its last item.
export type VelocityMarqueeTextItem = {
/** A word or phrase. */
text: string;
};
export type VelocityMarqueeImageItem = {
/** The image address. */
src: string;
/** Alt text. Leave it out for a purely decorative image. */
alt?: string;
};
export type VelocityMarqueeItem = VelocityMarqueeTextItem | VelocityMarqueeImageItem;
export type VelocityMarqueeProps = {
/** What the rows are made of, in order: words and images. */
items: VelocityMarqueeItem[];
/** How fast it drifts when nobody scrolls. 1 is about 90 pixels a second. */
speed?: number;
/** How much scrolling speeds it up. 0 ignores the scroll speed, 4 is a strong push. */
velocityBoost?: number;
/** The most it leans, in degrees, when the page scrolls fast. 0 never leans. */
skew?: number;
/** Which way the first row flows when the visitor scrolls down. Scrolling up flips it. */
direction?: "left" | "right";
/** Space between the items, in pixels. */
gap?: number;
/** How many rows to stack, one to three. They flow in alternating directions. */
rows?: number;
/** Draw the words of every second row as outlines. */
outlineAlternate?: boolean;
/** The element that scrolls, or a selector for it. Leave it out to follow the window. */
scroller?: HTMLElement | string | null;
/** Extra classes for the outer box. */
className?: string;
/** Classes for every item: the type size and weight live here. Images are sized in em, so they follow the text. */
itemClassName?: string;
/** Classes for the image items. */
imageClassName?: string;
};
// useLayoutEffect warns during server rendering, so fall back to useEffect there
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
function resolveScroller(scroller: VelocityMarqueeProps["scroller"]): HTMLElement | null {
if (!scroller) return null;
if (typeof scroller === "string") return document.querySelector<HTMLElement>(scroller);
return scroller;
}
/** Row 1 starts a few items further along than row 0, so the rows never read the same line. */
function rotate<T>(list: T[], by: number): T[] {
if (list.length === 0) return list;
const shift = ((by % list.length) + list.length) % list.length;
return [...list.slice(shift), ...list.slice(0, shift)];
}
export function VelocityMarquee({
items,
speed = 1,
velocityBoost = 4,
skew = 8,
direction = "left",
gap = 48,
rows = 2,
outlineAlternate = true,
scroller = null,
className = "",
itemClassName = "text-[clamp(64px,13vw,200px)] font-medium leading-[1.15] tracking-[-0.04em]",
imageClassName = "rounded-full",
}: VelocityMarqueeProps) {
const rootRef = useRef<HTMLDivElement>(null);
const stackRef = useRef<HTMLDivElement>(null);
const rowCount = Math.min(3, Math.max(1, Math.round(rows)));
const [copies, setCopies] = useState(2);
// The width of one set per row, in pixels. Written by the measuring effect, read every frame.
const setWidths = useRef<number[]>([]);
// These change from the playground without rebuilding anything: the loop reads them each frame
const live = useRef({ speed, velocityBoost, skew, direction });
live.current = { speed, velocityBoost, skew, direction };
// Measure one set per row and work out how many copies fill the box
useIsomorphicLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const sets = Array.from(root.querySelectorAll<HTMLElement>("[data-first-set]"));
const widths = sets.map(() => 0);
// The observer's contentRect is the layout size with its fraction (offsetWidth would round,
// and getBoundingClientRect would include the skew), so the wrap lands exactly on the repeat
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const row = sets.indexOf(entry.target as HTMLElement);
if (row !== -1) widths[row] = entry.contentRect.width;
});
setWidths.current = [...widths];
const boxWidth = root.clientWidth;
const needed = widths.reduce((most, width) => (width > 0 ? Math.max(most, Math.ceil(boxWidth / width) + 1) : most), 2);
setCopies((current) => (current === needed ? current : needed));
});
// The box resizing, fonts loading and a changed gap all change a set's width
observer.observe(root);
sets.forEach((set) => observer.observe(set));
return () => observer.disconnect();
}, [rowCount]);
useGSAP(
() => {
const root = rootRef.current;
const stack = stackRef.current;
if (!root || !stack) return;
const tracks = Array.from(root.querySelectorAll<HTMLElement>("[data-track]"));
const scrollElement = resolveScroller(scroller);
const readScroll = () => (scrollElement ? scrollElement.scrollTop : window.scrollY);
// Reduced motion: the CSS already lays the words out as a still, wrapping row. We add nothing.
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
const slideTo = tracks.map((track) => gsap.quickSetter(track, "x", "px"));
const lean = gsap.quickSetter(stack, "skewX", "deg");
const offsets = tracks.map(() => 0);
let lastScroll = readScroll();
let velocity = 0; // pixels per second, smoothed. Positive means scrolling down.
let scrollSign = 1; // the last direction the visitor scrolled in
let flip = 1; // eases from 1 to -1 and back, so a turn-around slows down first
let visible = true;
const tick = (_time: number, deltaMs: number) => {
// A long frame (a background tab, a debugger) would fling the rows, so cap it
const dt = Math.min(deltaMs, 50) / 1000;
if (dt <= 0) return;
const scrollNow = readScroll();
const rawVelocity = (scrollNow - lastScroll) / dt;
lastScroll = scrollNow;
if (!visible) return;
// Wheel ticks arrive in bursts, so smooth them: it speeds up fast and settles slowly
velocity += (rawVelocity - velocity) * (1 - Math.exp(-dt * 7));
if (Math.abs(velocity) > 30) scrollSign = Math.sign(velocity);
flip += (scrollSign - flip) * (1 - Math.exp(-dt * 5));
const { speed: baseSpeed, velocityBoost: boost, skew: maxSkew, direction: baseDirection } = live.current;
const push = 1 + boost * Math.min(Math.abs(velocity) / 1500, 2);
const pixelsPerSecond = baseSpeed * 90 * push * flip;
const firstRowSign = baseDirection === "left" ? 1 : -1;
tracks.forEach((_, row) => {
const width = setWidths.current[row] ?? 0;
if (width <= 0) return;
const rowSign = row % 2 === 0 ? firstRowSign : -firstRowSign;
offsets[row] = (offsets[row]! + rowSign * pixelsPerSecond * dt) % width;
// Always between -width and 0, so a copy is on screen before the previous one leaves
slideTo[row]!(-(((offsets[row]! % width) + width) % width));
});
lean(firstRowSign * Math.max(-1, Math.min(1, velocity / 1500)) * maxSkew);
};
gsap.ticker.add(tick);
// No work while the marquee is off screen
const visibility = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true;
});
visibility.observe(root);
return () => {
gsap.ticker.remove(tick);
visibility.disconnect();
gsap.set([...tracks, stack], { clearProps: "transform" });
};
});
return () => mm.revert();
},
{ scope: rootRef, dependencies: [rowCount, scroller], revertOnUpdate: true },
);
const renderItem = (item: VelocityMarqueeItem, index: number, outline: boolean) => (
<div key={index} className={`flex shrink-0 items-center ${itemClassName}`} style={{ paddingRight: gap }}>
{"src" in item ? (
<img src={item.src} alt={item.alt ?? ""} draggable={false} decoding="async" className={`block h-[0.8em] w-[1.4em] object-cover ${imageClassName}`} />
) : (
<span className="whitespace-nowrap" style={outline ? { WebkitTextFillColor: "transparent", WebkitTextStroke: "0.02em currentColor", letterSpacing: "-0.01em" } : undefined}>
{item.text}
</span>
)}
</div>
);
return (
<div ref={rootRef} className={`relative w-full overflow-hidden ${className}`}>
<div ref={stackRef} className="flex flex-col will-change-transform">
{Array.from({ length: rowCount }, (_, row) => {
const rowItems = rotate(items, row * Math.ceil(items.length / 3));
const outline = outlineAlternate && row % 2 === 1;
return (
// Rows after the first say the same thing again, so screen readers skip them
<div
key={row}
data-track=""
aria-hidden={row > 0 ? true : undefined}
className={`flex w-max will-change-transform motion-reduce:w-full motion-reduce:flex-wrap motion-reduce:justify-center ${row > 0 ? "motion-reduce:hidden" : ""}`}
>
{Array.from({ length: copies }, (_, copy) => (
<div
key={copy}
data-first-set={copy === 0 ? "" : undefined}
aria-hidden={copy > 0 ? true : undefined}
className={`flex shrink-0 items-center motion-reduce:w-full motion-reduce:shrink motion-reduce:flex-wrap motion-reduce:justify-center ${copy > 0 ? "motion-reduce:hidden" : ""}`}
>
{rowItems.map((item, index) => renderItem(item, index, outline))}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
The component. Installs to components/dimaac/velocity-marquee.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.jsonIt adds components/dimaac/velocity-marquee.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/velocity-marquee.
By hand
npm install gsap @gsap/react- Install the packages it uses: gsap, @gsap/react.
- Copy the component file below into components/dimaac/velocity-marquee.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { VelocityMarquee } from "@/components/dimaac/velocity-marquee" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items (required) | VelocityMarqueeItem[] | - | What the rows are made of, in order: words and images. |
speed | number | 1 | How fast it drifts when nobody scrolls. 1 is about 90 pixels a second. |
velocityBoost | number | 4 | How much scrolling speeds it up. 0 ignores the scroll speed, 4 is a strong push. |
skew | number | 8 | The most it leans, in degrees, when the page scrolls fast. 0 never leans. |
direction | "left" | "right" | "left" | Which way the first row flows when the visitor scrolls down. Scrolling up flips it. |
gap | number | 48 | Space between the items, in pixels. |
rows | number | 2 | How many rows to stack, one to three. They flow in alternating directions. |
outlineAlternate | boolean | true | Draw the words of every second row as outlines. |
scroller | HTMLElement | string | null | null | The element that scrolls, or a selector for it. Leave it out to follow the window. |
className | string | "" | Extra classes for the outer box. |
itemClassName | string | "text-[clamp(64px,13vw,200px)] font-medium leading-[1.15] tracking-[-0.04em]" | Classes for every item: the type size and weight live here. Images are sized in em, so they follow the text. |
imageClassName | string | "rounded-full" | Classes for the image items. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the Velocity Marquee component (<VelocityMarquee />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/velocity-marquee
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/velocity-marquee.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json
Then use it like this:
import { VelocityMarquee } from "@/components/dimaac/velocity-marquee";
<VelocityMarquee items={items} />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/velocity-marquee.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- velocity-marquee.md
- This page as Markdown: install, props and the full source
- velocity-marquee.json
- The shadcn registry item
About
An endless marquee of big words and small pictures. It drifts on its own; scroll the page and it speeds up and leans into the movement, scroll back up and it turns around. Stack up to three rows and they run in alternating directions, with every second row drawn as outlines if you like. The loop is measured and repeated to fit any width, so it never jumps, it sleeps while it is off screen, and under reduced motion it becomes a still, wrapping row of the same words and pictures. Works with the window or any scrolling element.
- Installs to
components/dimaac/velocity-marquee.tsx- Uses
- gsap, @gsap/react
- Tags
- gsap, marquee, scroll, velocity, typography
Photo by West Kast, Dona Mara, rena, Pablo Stanley, marisol, Amino, SHIHO and Viri Gutiérrez on Lummi (Lummi License).