Skip to content
Uxelith
Sign in
Freev1.0.0Text & Motion

Marquee

Content that loops past across or up and down with no gaps, at a steady speed, with soft faded edges. Pauses on hover and keyboard focus.

pnpm dlx shadcn add …/r/marquee.json
Licence
MIT
Packages
2
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/marquee.json
  2. It adds these packages for you:

    clsxtailwind-merge
  3. The source lands in your components folder as marquee.tsx. It's yours: change anything.

Usage

example.tsx
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.

marquee.tsx
"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

PropTypeDefaultDescription
durationnumber30Seconds for one loop. Ignored when speed is set.
speednumber—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).
gapstring"2.5rem"Space between items, any CSS length.
fadebooleantrueFade out at both edges.
pauseOnHoverbooleantruePause while hovered. Keyboard focus always pauses it.
labelstring—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

  1. v1.0.0Latest

    First release.

More in Text & Motion

All 4 in Text & Motion
  • Text & MotionFree

    Blur Text

    Text that comes into focus word by word or letter by letter, then brings out the words you choose: they take the accent colour while a line draws beneath them. Screen readers get the sentence once.

  • Text & MotionFree

    Shimmer Text

    Text with a band of light that sweeps across it, sized to the text so short labels and long headings move at the same pace. Settles to plain text when the work is done. Pure CSS, renders on the server.

  • Text & MotionFree

    Typewriter

    Text that types itself out with a human rhythm, a caret that holds steady while typing and blinks when idle, and words that are backspaced or selected and replaced. Starts when scrolled into view; screen readers get the sentence once.