← Components

Velocity Marquee

A marquee that speeds up, leans and turns around with your scroll.

Text · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

1.0
4.0
8.0 deg
48 px
2
Your code
<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.json

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

Props

PropTypeDefaultDescription
items (required)VelocityMarqueeItem[]-What the rows are made of, in order: words and images.
speednumber1How fast it drifts when nobody scrolls. 1 is about 90 pixels a second.
velocityBoostnumber4How much scrolling speeds it up. 0 ignores the scroll speed, 4 is a strong push.
skewnumber8The 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.
gapnumber48Space between the items, in pixels.
rowsnumber2How many rows to stack, one to three. They flow in alternating directions.
outlineAlternatebooleantrueDraw the words of every second row as outlines.
scrollerHTMLElement | string | nullnullThe element that scrolls, or a selector for it. Leave it out to follow the window.
classNamestring""Extra classes for the outer box.
itemClassNamestring"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.
imageClassNamestring"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.

Prompt
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

Files 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).