# Velocity Band (`velocity-band`)

> A typographic marquee whose drift speed and skew react to scroll velocity — rows alternate direction and lean into hard scrolls.

- **Docs:** https://www.mellowui.com/components/velocity-band
- **Markdown:** https://www.mellowui.com/components/velocity-band.md
- **Registry:** https://www.mellowui.com/r/velocity-band.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/velocity-band
- **Categories:** display, marquee, scroll, animation
- **Dependencies:** none

## AI prompt

Add a VelocityBand component from the mellow library. It renders rows of large typographic marquee text that drift continuously; scroll velocity boosts the drift speed and skews the rows so the band leans into the motion. Rows alternate direction and odd rows are dimmed. Props: rows (string[][], one array of words per row), baseSpeed (px/s, default 54), velocityBoost (default 200), maxSkew (deg, default 6), serifAlternate (alternate serif-italic/sans words, default true). Respects prefers-reduced-motion by rendering static rows.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/velocity-band.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `rows` | `string[][]` | — | One array of words per marquee row. Rows alternate direction. |
| `baseSpeed` | `number` | `54` | Base drift speed in px/s when the page is still. |
| `velocityBoost` | `number` | `200` | How much scroll velocity boosts the drift. 0 disables the reaction. |
| `maxSkew` | `number` | `6` | Maximum skew angle in degrees at high scroll velocity. |
| `serifAlternate` | `boolean` | `true` | Alternate serif-italic and sans words. False for all-sans. |
| `className` | `string` | — | Additional CSS classes for the wrapper. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef } from "react";

export interface VelocityBandProps {
  /** One array of words per marquee row. Rows alternate direction. */
  rows?: string[][];
  /** Base drift speed in px/s when the page is still. */
  baseSpeed?: number;
  /** How much scroll velocity boosts the drift (0 disables the reaction). */
  velocityBoost?: number;
  /** Maximum skew angle (deg) applied at high scroll velocity. */
  maxSkew?: number;
  /** Alternate serif-italic / sans words. Set false for all-sans. */
  serifAlternate?: boolean;
  /** Read scroll velocity from this element instead of the window. */
  containerRef?: React.RefObject<HTMLElement | null>;
  className?: string;
  style?: React.CSSProperties;
}

function MarqueeRow({
  words,
  serifAlternate,
  rowRef,
}: {
  words: string[];
  serifAlternate: boolean;
  rowRef: (el: HTMLDivElement | null) => void;
}) {
  const items = [...words, ...words, ...words, ...words];
  return (
    <div className="flex overflow-hidden whitespace-nowrap">
      <div ref={rowRef} className="flex shrink-0 items-baseline will-change-transform">
        {items.map((word, i) => (
          <span key={i} className="flex shrink-0 items-baseline">
            <span
              className={
                serifAlternate && i % 2 === 0
                  ? "font-serif text-[clamp(2.75rem,7vw,6rem)] leading-[1.1] text-(--ink) italic"
                  : "font-sans text-[clamp(2.75rem,7vw,6rem)] leading-[1.1] font-medium tracking-[-0.04em] text-(--ink)"
              }
            >
              {word}
            </span>
            <span
              aria-hidden="true"
              className="mx-[clamp(1.25rem,3vw,2.75rem)] inline-block h-[6px] w-[6px] translate-y-[-0.5em] rounded-full bg-[rgba(var(--ink-rgb),0.25)]"
            />
          </span>
        ))}
      </div>
    </div>
  );
}

/**
 * A typographic marquee whose drift speed and skew react to scroll velocity.
 * Rows alternate direction; scroll hard and the band leans into the motion.
 */
export function VelocityBand({
  rows = [
    ["Copy", "Paste", "Own", "Texture", "Timing", "Theatre"],
    ["Motion", "Editorial", "Canvas", "Serif", "Mono", "Ink"],
  ],
  baseSpeed = 54,
  velocityBoost = 200,
  maxSkew = 6,
  serifAlternate = true,
  containerRef,
  className,
  style,
}: VelocityBandProps) {
  const sectionRef = useRef<HTMLDivElement>(null);
  const rowRefs = useRef<(HTMLDivElement | null)[]>([]);

  useEffect(() => {
    const section = sectionRef.current;
    const els = rowRefs.current.filter(Boolean) as HTMLDivElement[];
    if (!section || els.length === 0) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    if (reduced) return;

    const readScroll = () =>
      containerRef?.current ? containerRef.current.scrollTop : window.scrollY;

    let raf = 0;
    let running = false;
    const offsets = els.map(() => 0);
    let lastScroll = readScroll();
    let velocity = 0;
    let skew = 0;
    let lastTime = performance.now();

    const frame = (now: number) => {
      if (!running) return;
      const dt = Math.min((now - lastTime) / 1000, 0.05);
      lastTime = now;

      const sy = readScroll();
      const instV = (sy - lastScroll) / Math.max(dt, 0.001);
      lastScroll = sy;
      velocity += (instV - velocity) * 0.12;

      const boost = Math.min(Math.abs(velocity) / 900, 3);
      const speed = (baseSpeed + boost * velocityBoost) * dt;

      const targetSkew = Math.max(
        -maxSkew,
        Math.min(maxSkew, velocity / 260)
      );
      skew += (targetSkew - skew) * 0.1;

      els.forEach((el, i) => {
        const dir = i % 2 === 0 ? -1 : 1;
        const half = el.scrollWidth / 2;
        offsets[i] += speed * dir;
        if (offsets[i] <= -half) offsets[i] += half;
        if (offsets[i] >= 0) offsets[i] -= half;
        el.style.transform = `translate3d(${offsets[i]}px,0,0) skewX(${-skew}deg)`;
      });

      raf = requestAnimationFrame(frame);
    };

    const start = () => {
      if (running) return;
      running = true;
      lastTime = performance.now();
      lastScroll = readScroll();
      raf = requestAnimationFrame(frame);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };

    const io = new IntersectionObserver(
      ([entry]) => (entry.isIntersecting ? start() : stop()),
      { threshold: 0 }
    );
    io.observe(section);

    const onVisibility = () => {
      if (document.hidden) stop();
      else if (section.getBoundingClientRect().bottom > 0) start();
    };
    document.addEventListener("visibilitychange", onVisibility);

    return () => {
      stop();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [baseSpeed, velocityBoost, maxSkew, rows.length, containerRef]);

  return (
    <div
      ref={sectionRef}
      aria-hidden="true"
      className={["flex flex-col gap-2 overflow-hidden", className]
        .filter(Boolean)
        .join(" ")}
      style={style}
    >
      {rows.map((words, i) => (
        <div key={i} className={i % 2 === 1 ? "opacity-40" : undefined}>
          <MarqueeRow
            words={words}
            serifAlternate={serifAlternate}
            rowRef={(el) => {
              rowRefs.current[i] = el;
            }}
          />
        </div>
      ))}
    </div>
  );
}

export default VelocityBand;

```

## Demo

```tsx
"use client";

import React from "react";
import { VelocityBand } from "../mellow/velocity-band";

export default function VelocityBandDemo() {
  return (
    <div className="flex h-full w-full flex-col items-center justify-center gap-6 py-10">
      <VelocityBand
        className="w-full border-y border-(--rule) py-6"
        rows={[
          ["Copy", "Paste", "Own", "Texture", "Timing", "Theatre"],
          ["Motion", "Editorial", "Canvas", "Serif", "Mono", "Ink"],
        ]}
      />
      <p className="font-mono text-[0.625rem] tracking-[0.24em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
        Scroll the page — the band leans into your velocity
      </p>
    </div>
  );
}

```
