Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Velocity Band

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

MSource LandMellow UI

Why it stands out

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

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/velocity-band-demo.tsx

File content: "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-5 py-6 sm:gap-6 sm: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="px-5 text-center 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>
  );
}



File location: components/mellow/velocity-band.tsx

File content: "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;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI