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.jsonCSSno 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
"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
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React