Skip to content
goodtabs UI
BrowseBrowse components

Velocity marquee

A marquee whose speed and direction follow scroll velocity, smoothed per frame.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/velocity-marquee.json

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

Two rows in a pinned stage, drifting apart. Scroll the frame to drive them.

Usage

import { VelocityMarquee } from "@/components/ui/velocity-marquee"

<VelocityMarquee aria-label="Details we sweat" className="text-7xl font-medium">
  <span className="whitespace-nowrap">Hairlines</span>
  <span className="whitespace-nowrap">Focus rings</span>
</VelocityMarquee>

Props

PropTypeDefaultDescription
speednumber48Resting drift in pixels per second.
direction1 | -111 drifts left on the way down the page, -1 drifts right. Scrolling up reverses either.
gainnumber0.35Extra pixels per second of drift for each pixel per second of scroll.
smoothingnumber6Rate k in 1 - e^(-k dt). Higher follows the scroll more tightly.
gapnumber48Space between items in pixels, repeated at the seam.

Source

velocity-marquee/velocity-marquee.tsx
"use client";

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP);

const MOTION = "(prefers-reduced-motion: no-preference)";
const POINTER = "(hover: hover) and (pointer: fine)";

type VelocityMarqueeProps = React.ComponentProps<"div"> & {
  /** Resting drift in pixels per second. */
  speed?: number;
  /** 1 drifts left on the way down the page, -1 drifts right. Scrolling up reverses either. */
  direction?: 1 | -1;
  /** Extra pixels per second of drift for each pixel per second of scroll. */
  gain?: number;
  /** Rate k in 1 - e^(-k dt). Higher follows the scroll more tightly. */
  smoothing?: number;
  /** Space between items in pixels, repeated at the seam. */
  gap?: number;
};

/**
 * A marquee whose speed and direction follow scroll velocity, smoothed per frame so it behaves the same
 * at any refresh rate. Hover and keyboard focus stop the drift, and the scroll term still moves it,
 * because that motion belongs to the person scrolling. Under reduced motion it is a static, wrapped row.
 */
function VelocityMarquee({
  speed = 48,
  direction = 1,
  gain = 0.35,
  smoothing = 6,
  gap = 48,
  className,
  children,
  ...props
}: VelocityMarqueeProps) {
  const root = React.useRef<HTMLDivElement>(null);
  const [copies, setCopies] = React.useState(3);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(MOTION, () => {
        const element = root.current;
        const track = element?.querySelector<HTMLElement>("[data-slot=velocity-marquee-track]");
        const set = element?.querySelector<HTMLElement>("[data-slot=velocity-marquee-set]");
        if (!element || !track || !set) return;

        let width = 0;
        let x = 0;
        let velocity = 0;
        let heading = 1;
        let lastY = window.scrollY;
        let hovered = false;
        let focused = false;

        const measure = () => {
          width = set.getBoundingClientRect().width;
          if (width) setCopies(Math.min(Math.ceil(element.clientWidth / width) + 1, 12));
        };

        const tick = (_: number, deltaMs: number) => {
          if (!width || !deltaMs) return;
          const y = window.scrollY;
          const scroll = (y - lastY) / (deltaMs / 1000);
          lastY = y;
          if (Math.abs(scroll) > 1) heading = Math.sign(scroll);
          const dt = Math.min(deltaMs / 1000, 0.05);
          const target = (hovered || focused ? 0 : heading * speed) + scroll * gain;
          velocity += (target - velocity) * (1 - Math.exp(-smoothing * dt));
          // Past the end of one set the next is identical, so wrapping by its width is invisible.
          x = gsap.utils.wrap(-width, 0, x - velocity * direction * dt);
          track.style.transform = `translate3d(${x}px, 0, 0)`;
        };

        // The loop only runs while the row is on screen.
        const watcher = new IntersectionObserver((entries) => {
          gsap.ticker.remove(tick);
          if (!entries[entries.length - 1].isIntersecting) return;
          lastY = window.scrollY;
          gsap.ticker.add(tick);
        });
        watcher.observe(element);

        const resizer = new ResizeObserver(measure);
        resizer.observe(element);
        resizer.observe(set);
        void document.fonts.ready.then(measure);

        const pointer = window.matchMedia(POINTER).matches;
        const onEnter = () => {
          hovered = true;
        };
        const onLeave = () => {
          hovered = false;
        };
        const onFocusIn = (e: FocusEvent) => {
          focused = (e.target as HTMLElement).matches(":focus-visible");
        };
        const onFocusOut = () => {
          focused = false;
        };
        if (pointer) {
          element.addEventListener("pointerenter", onEnter);
          element.addEventListener("pointerleave", onLeave);
        }
        element.addEventListener("focusin", onFocusIn);
        element.addEventListener("focusout", onFocusOut);

        return () => {
          gsap.ticker.remove(tick);
          watcher.disconnect();
          resizer.disconnect();
          element.removeEventListener("pointerenter", onEnter);
          element.removeEventListener("pointerleave", onLeave);
          element.removeEventListener("focusin", onFocusIn);
          element.removeEventListener("focusout", onFocusOut);
          track.style.transform = "";
        };
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [speed, direction, gain, smoothing] },
  );

  const row = { gap, paddingRight: gap };
  return (
    <div
      ref={root}
      data-slot="velocity-marquee"
      role="group"
      tabIndex={0}
      className={cn("-my-[0.16em] overflow-clip py-[0.16em] focus-visible:-outline-offset-2", className)}
      {...props}
    >
      <div data-slot="velocity-marquee-track" className="flex w-max motion-reduce:w-auto">
        <div
          data-slot="velocity-marquee-set"
          style={row}
          className="flex shrink-0 items-center motion-reduce:shrink motion-reduce:flex-wrap motion-reduce:justify-center motion-reduce:pr-0!"
        >
          {children}
        </div>
        {Array.from({ length: copies - 1 }, (_, i) => (
          <div key={i} aria-hidden="true" inert style={row} className="flex shrink-0 items-center motion-reduce:hidden">
            {children}
          </div>
        ))}
      </div>
    </div>
  );
}

export { VelocityMarquee };

For AI agents

Hand your agent this page, the markdown at /components/velocity-marquee/prompt.md, or the Copy AI prompt button above.

When to use

  • A big type band between two sections of a landing page
  • A row of customer names or tools, where order does not matter
  • A page with enough scroll for the velocity to be felt

When not to use

  • Content people have to read in full
  • Short pages with nothing to scroll
  • Inside a nested scroller: it reads the window's scroll

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • Give it an aria-label. It is a focusable group, so a keyboard user can stop the drift. Clones are aria-hidden and inert
  • Hover and focus stop the resting drift. Scrolling still moves it, because that motion is the person's own and a full stop would freeze it under a resting pointer
  • Under reduced motion the clones go and the first set wraps as a centred static row, so nothing is cut off
  • Scroll velocity comes from window.scrollY each frame, so it needs the window as the scroller. The loop stops while the row is off screen

The maths

  • Velocity eases by v += (target - v) × (1 - e^(-k dt)), so k = 6 covers 63 percent of the gap in 167ms at any refresh rate.
  • Target is heading × speed + scroll × gain, so 2000px/s of scroll adds 700px/s at the default gain.
  • Copies are ceil(W / set) + 1 and x wraps by one set's width, so the seam never shows.