Marquee

A row that scrolls forever, horizontally or vertically. Stops under reduced motion with every item still visible — a marquee that hid its content when stilled would be worse than one that never moved.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/marquee.json
CSSno JS
packet-fieldember-fieldconstellationlabel-driftatmospheremasked-gridpointer-fieldedge-flowtype-logsystem-monitorcode-reelchat-reelshot-reelrequest-reelcode-typeangle-ringnumber-tickerblur-fadeseamspot-card
marqueeshimmer-buttonscramble-texttext-rotatesparklinespark-bardelta-chipstat-tilearc-revealgaugestream-linecell-flashchart-revealrow-cascadeskeleton-swaporb-loaderring-spinnerpulse-gridtop-progress

Marqueeesc or double-click to exit full screendouble-tap to exit

primitives/marquee.tsx
"use client";

import * as React from "react";
import { cx, inertValue } from "@pouriahlabs/wisp-ui";

/**
 * Marquee — a row that scrolls forever.
 *
 * The children are repeated so that as one copy translates out, the next is
 * already in frame; `repeat` controls how many copies exist. Four is right for
 * a handful of wide items and wasteful for many narrow ones — set it to the
 * smallest number that still fills your widest breakpoint.
 *
 * Under reduced motion the row simply stops, which leaves every item visible
 * and readable. That is the composed still frame for this component: a marquee
 * that hid its content when stilled would be worse than one that never moved.
 *
 * The keyframes live in `@pouriahlabs/wisp-ui/styles.css`, not in your Tailwind
 * config — so installing this component needs no build-config edit.
 *
 * The filler copies are both `aria-hidden` and `inert` — `aria-hidden` alone
 * hides them from the accessibility tree but does nothing about the tab key,
 * so a focusable child (a link, a button) inside a "hidden" copy was still
 * reachable, and reachable in an order that made no visual sense. If your
 * children carry `id`s, repeating them duplicates those `id`s in the DOM;
 * that's an inherent cost of the repeat-copies technique, not something this
 * component can fix on your behalf — don't rely on `id`-based selectors or
 * `<label for>` inside marquee content.
 */

export interface MarqueeProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Milliseconds for one full pass. */
  duration?: number;
  /** Space between items; any CSS length. */
  gap?: string;
  /** Travel right-to-left instead. */
  reverse?: boolean;
  /** Scroll vertically. */
  vertical?: boolean;
  /** Copies of the children rendered back to back. */
  repeat?: number;
  pauseOnHover?: boolean;
}

export function Marquee({
  duration = 40000,
  gap = "1rem",
  reverse = false,
  vertical = false,
  repeat = 4,
  pauseOnHover = true,
  className,
  style,
  children,
  ...props
}: MarqueeProps) {
  return (
    <div
      className={cx(
        "wisp-marquee group/marquee",
        vertical ? "is-vertical" : "is-horizontal",
        pauseOnHover && "is-pausable",
        className,
      )}
      style={
        {
          "--wisp-marquee-duration": `${duration}ms`,
          "--wisp-marquee-gap": gap,
          ...style,
        } as React.CSSProperties
      }
      {...props}
    >
      {Array.from({ length: repeat }, (_, i) => (
        // Only the first copy is real content; the rest are visual filler —
        // `aria-hidden` keeps them out of the accessibility tree, and `inert`
        // is what actually keeps their focusable children out of tab order.
        <div
          key={i}
          className="wisp-marquee-track"
          aria-hidden={i > 0}
          inert={inertValue(i > 0)}
          data-reverse={reverse ? "" : undefined}
        >
          {children}
        </div>
      ))}
    </div>
  );
}

Certified against the contract