Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/marquee.jsonIt adds these packages for you:
clsxtailwind-mergeThe source lands in your components folder as
marquee.tsx. It's yours: change anything.
Usage
import { Marquee } from "@/components/marquee"
export function Logos() {
return (
<Marquee label="Customers">
<span>Northwind</span>
<span>Lumen</span>
<span>Halcyon</span>
</Marquee>
)
}Source
One file, 117 lines, nothing hidden. Read it before you install it.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface MarqueeProps extends React.ComponentProps<"div"> {
/** Seconds for one full loop. Higher is slower. Ignored when `speed` is set. */
duration?: number;
/** Pixels per second, so rows of different lengths move at the same pace. Overrides `duration`. */
speed?: number;
/** Which way the content travels. "up" and "down" make a vertical marquee; give it a height. */
direction?: "left" | "right" | "up" | "down";
/** Gap between items, and between the end of the content and its repeat (any CSS length). */
gap?: string;
/** Fade the content out at both edges. */
fade?: boolean;
/** Pause while hovered. It always pauses while something inside has keyboard focus. */
pauseOnHover?: boolean;
/** Accessible name for the region, e.g. "Customers". */
label?: string;
}
// Server-rendered copies before the row is measured: the content and one repeat.
const MIN_COPIES = 2;
/**
* Content that scrolls past in an endless loop — logos, testimonials, tags —
* across or up and down.
*
* - The loop never shows a gap: it repeats the content as many times as the
* width (or height) needs, and re-measures when either changes.
* - `speed` keeps rows of different lengths moving at one pace.
* - The repeats are hidden from screen readers and can't be tabbed into, so
* everything is announced and focused once.
* - It pauses while hovered or while anything inside has keyboard focus
* (WCAG 2.2.2), and it doesn't move at all under `prefers-reduced-motion` —
* the row becomes scrollable instead.
* - Needs the `ux-marquee` and `ux-marquee-y` keyframes, which the registry
* adds to your CSS.
*/
export function Marquee({
duration = 30,
speed,
direction = "left",
gap = "2.5rem",
fade = true,
pauseOnHover = true,
label,
className,
style,
children,
...props
}: MarqueeProps) {
const vertical = direction === "up" || direction === "down";
const root = React.useRef<HTMLDivElement>(null);
const first = React.useRef<HTMLDivElement>(null);
const [copies, setCopies] = React.useState(MIN_COPIES);
React.useLayoutEffect(() => {
const box = root.current;
const track = first.current;
if (!box || !track || typeof ResizeObserver === "undefined") return;
const measure = () => {
const size = vertical ? box.clientHeight : box.clientWidth;
const length = vertical ? track.offsetHeight : track.offsetWidth;
if (!length) return;
setCopies(Math.max(MIN_COPIES, Math.ceil(size / length) + 1));
if (speed) box.style.setProperty("--marquee-duration", `${length / speed}s`);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(box);
observer.observe(track);
return () => observer.disconnect();
}, [vertical, speed]);
const track = cn(
"flex shrink-0 items-center gap-[var(--marquee-gap)]",
vertical ? "flex-col pb-[var(--marquee-gap)]" : "pr-[var(--marquee-gap)]",
vertical ? "animate-[ux-marquee-y_var(--marquee-duration)_linear_infinite]" : "animate-[ux-marquee_var(--marquee-duration)_linear_infinite]",
pauseOnHover && "group-hover/marquee:[animation-play-state:paused]",
"group-focus-within/marquee:[animation-play-state:paused]",
(direction === "right" || direction === "down") && "[animation-direction:reverse]",
"motion-reduce:animate-none",
);
return (
<div
ref={root}
role={label ? "region" : undefined}
aria-label={label}
data-orientation={vertical ? "vertical" : "horizontal"}
className={cn(
"group/marquee flex overflow-hidden",
vertical ? "flex-col motion-reduce:overflow-y-auto" : "w-full motion-reduce:overflow-x-auto",
fade &&
(vertical
? "[mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]"
: "[mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)]"),
className,
)}
style={{ "--marquee-duration": `${duration}s`, "--marquee-gap": gap, ...style } as React.CSSProperties}
{...props}
>
<div ref={first} className={track}>
{children}
</div>
{/* The repeats: decorative, unfocusable, and gone when motion is reduced. */}
{Array.from({ length: copies - 1 }, (_, i) => (
<div key={i} className={cn(track, "motion-reduce:hidden")} aria-hidden="true" inert>
{children}
</div>
))}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| duration | number | 30 | Seconds for one loop. Ignored when speed is set. |
| speed | number | — | Pixels per second, so rows of any length move at one pace. |
| direction | "left" | "right" | "up" | "down" | "left" | Which way it travels; up and down are vertical (give it a height). |
| gap | string | "2.5rem" | Space between items, any CSS length. |
| fade | boolean | true | Fade out at both edges. |
| pauseOnHover | boolean | true | Pause while hovered. Keyboard focus always pauses it. |
| label | string | — | Names the region for screen readers, e.g. "Customers". |
Accessibility
- The repeats that make the loop seamless are aria-hidden and inert, so content is read and focused once.
- Pauses while hovered or while anything inside has keyboard focus (WCAG 2.2.2).
- Under prefers-reduced-motion it stops and becomes a scrollable row (or column).
Changelog
- v1.0.0Latest
First release.