---
title: Velocity Marquee
description: A marquee that speeds up, leans and turns around with your scroll.
category: Text
tier: free
url: https://ui.dimaac.com/components/velocity-marquee
registry: https://ui.dimaac.com/r/velocity-marquee.json
---

# Velocity Marquee

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/velocity-marquee`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/velocity-marquee.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { VelocityMarquee } from "@/components/dimaac/velocity-marquee" and give its parent a size: it fills it.

## Usage

```tsx
import { VelocityMarquee } from "@/components/dimaac/velocity-marquee";

<VelocityMarquee items={items} />
```

## 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. |

## Credits

- Photo: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Photo: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Photo: Pablo Stanley, Lummi (https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd), Lummi License
- Photo: marisol, Lummi (https://www.lummi.ai/photo/glossy-pink-sphere-45si5), Lummi License
- Photo: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Photo: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Photo: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License

## Source

### velocity-marquee.tsx → `components/dimaac/velocity-marquee.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { VelocityMarquee, type VelocityMarqueeItem, type VelocityMarqueeProps } from "@/components/dimaac/velocity-marquee";

// A hero made of the marquee, a few pictures to scroll past, and a second,
// quieter marquee before the footer. Photos from Lummi (Lummi License).

const heroItems: VelocityMarqueeItem[] = [
  { text: "Scroll faster" },
  { src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg", alt: "A woman in an orange visor and navy dress against a teal background" },
  { text: "Slow down" },
  { src: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg", alt: "Yellow and beige walls under a clear blue sky" },
  { text: "Turn around" },
  { src: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg", alt: "A glossy pink sphere reflecting a cloudy sky" },
  { text: "Keep moving" },
  { src: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg", alt: "A person with curly red hair in a lime green blazer" },
];

const bandItems: VelocityMarqueeItem[] = [
  { text: "Keep scrolling" },
  { src: "https://ui.dimaac.com/media/art/colour-cylinders-4x5.jpg", alt: "Colourful cylinders on a geometric background" },
  { text: "Almost there" },
  { src: "https://ui.dimaac.com/media/nature/golden-dunes-3x2.jpg", alt: "Golden sand dunes under a blue sky" },
];

const pictures = [
  { src: "https://ui.dimaac.com/media/objects/amber-perfume-4x5.jpg", alt: "An amber glass perfume bottle against a green background with leaf shadows", label: "No. 01", title: "Amber bottle" },
  { src: "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg", alt: "A purple wall and a pink building under a blue sky", label: "No. 02", title: "Pastel rooftop" },
  { src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg", alt: "A woman in an orange visor and navy dress against a teal background", label: "No. 03", title: "Orange visor" },
];

export default function Demo(props: Partial<VelocityMarqueeProps>) {
  return (
    <div className="w-full bg-[#efece6] text-[#15130f] dark:bg-[#0d0c0b] dark:text-[#ece9e2]">
      <section className="flex min-h-[100dvh] flex-col justify-between py-6">
        <div aria-hidden="true" />

        <VelocityMarquee items={heroItems} {...props} />

        <footer className="flex items-end justify-between gap-6 px-6">
          <p className="max-w-[30ch] text-[15px] leading-snug opacity-65">The faster you scroll, the faster it runs. Scroll back up and it turns around.</p>
        </footer>
      </section>

      <section className="px-6 py-24 md:py-36">
        <p className="max-w-[22ch] text-[clamp(32px,5.5vw,76px)] leading-[1] font-medium tracking-[-0.04em]">Text that speeds up when you do.</p>
        <div className="mt-16 grid grid-cols-1 gap-6 md:mt-24 md:grid-cols-3">
          {pictures.map((picture) => (
            <figure key={picture.label} className="m-0">
              <img src={picture.src} alt={picture.alt} loading="lazy" className="aspect-[4/5] w-full rounded-[4px] object-cover" />
              <figcaption className="mt-3 flex justify-between font-mono text-[11px] tracking-wide uppercase opacity-70">
                <span>{picture.label}</span>
                <span>{picture.title}</span>
              </figcaption>
            </figure>
          ))}
        </div>
      </section>

      <section className="bg-[#d6ef4a] py-10 text-[#141414]">
        <VelocityMarquee items={bandItems} {...props} rows={1} itemClassName="text-[clamp(40px,7vw,100px)] font-medium leading-[1.2] tracking-[-0.04em]" />
      </section>

      <div className="h-8" aria-hidden="true" />
    </div>
  );
}
```

## Agent prompt

```text
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.txt
```
