# Marquee

> Infinite scrolling rows with edge fades and pause on hover.

Category: Effects. Docs: https://neonkit.heyaadi.com/components/marquee

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/marquee.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/marquee`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Marquee } from "@/components/ui/marquee"

<Marquee speed={40} gap={32}>
  <span>Open late</span>
  <span>Karaoke</span>
  <span>Noodles</span>
</Marquee>
```

## Examples

### Sign strip

Two rows running in opposite directions at different speeds. Hover to ease them to a stop.

```tsx
import { Marquee } from "@/components/ui/marquee";

const signs = ["Open late", "Karaoke", "Noodles", "Arcade", "Vacancy", "Live jazz", "Cocktails", "Pachinko"];
const cities = ["Hong Kong", "Tokyo", "Las Vegas", "Seoul", "Havana", "Berlin", "Taipei", "Reno"];

export default function MarqueeDemo() {
  return (
    <div className="relative w-full overflow-hidden rounded-2xl border border-white/10 bg-[#07080d] py-8 [--neon:var(--neon-accent,34_211_238)]">
      {/* Light the signs throw on the wall behind them */}
      <div
        aria-hidden
        className="absolute inset-x-0 top-1/2 h-40 -translate-y-1/2 bg-[radial-gradient(ellipse_55%_50%_at_50%_45%,rgb(var(--neon)/0.14),transparent)]"
      />
      <Marquee speed={36} gap={40} className="py-5">
        {signs.map((sign) => (
          <span key={sign} className="flex items-center gap-10">
            <span
              className="whitespace-nowrap text-4xl tracking-tight [color:color-mix(in_srgb,rgb(var(--neon))_30%,white)] [text-shadow:0_0_1px_rgb(255_255_255/0.9),0_0_6px_rgb(var(--neon)),0_0_16px_rgb(var(--neon)),0_0_38px_rgb(var(--neon)/0.6)] sm:text-5xl"
              style={{ fontFamily: "var(--font-tilt-neon)" }}
            >
              {sign}
            </span>
            <span aria-hidden className="size-1.5 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon))]" />
          </span>
        ))}
      </Marquee>
      <Marquee reverse speed={22} gap={28} className="mt-2 py-2">
        {cities.map((city) => (
          <span key={city} className="whitespace-nowrap text-sm text-white/55">
            {city}
          </span>
        ))}
      </Marquee>
    </div>
  );
}
```

### Logo wall

A longer fade lets names drift in and out of the dark.

```tsx
import { Hexagon, Mountain, Orbit, Triangle, Waves, Zap } from "@/components/ui/icons";
import { Marquee } from "@/components/ui/marquee";

const companies = [
  { name: "Halcyon", icon: Hexagon },
  { name: "Northwind", icon: Triangle },
  { name: "Orbital", icon: Orbit },
  { name: "Voltline", icon: Zap },
  { name: "Tidewater", icon: Waves },
  { name: "Summit Labs", icon: Mountain },
];

export default function MarqueeLogos() {
  return (
    <div className="w-full max-w-2xl">
      <p className="text-center text-sm text-white/50">Lighting up dashboards at</p>
      <Marquee speed={30} gap={56} fade={96} className="mt-6 py-2">
        {companies.map(({ name, icon: Icon }) => (
          <span key={name} className="flex items-center gap-2 whitespace-nowrap text-lg font-medium tracking-tight text-white/55">
            <Icon className="size-5" strokeWidth={1.75} />
            {name}
          </span>
        ))}
      </Marquee>
    </div>
  );
}
```

### Vertical

A vertical marquee needs a height.

```tsx
import { Marquee } from "@/components/ui/marquee";

const quotes = [
  { body: "Swapped our status page to it in an afternoon. The meters finally look like hardware.", name: "Ines Duarte", role: "SRE, Tidewater" },
  { body: "The glow only shows up where something is happening. That restraint is the whole trick.", name: "Kenji Mori", role: "Design lead, Orbital" },
  { body: "We copied one file and deleted four hundred lines of our own CSS.", name: "Priya Raman", role: "Frontend, Halcyon" },
  { body: "Keyboard support was already done. I checked every component and had nothing to file.", name: "Sam Okoro", role: "Accessibility, Northwind" },
  { body: "Our dashboard looked like a spreadsheet. Now it looks like a control room.", name: "Lena Fischer", role: "Founder, Voltline" },
  { body: "Reduced motion is handled everywhere, not just where someone remembered.", name: "Tomás Rey", role: "Engineer, Summit Labs" },
];

function Quote({ body, name, role }: (typeof quotes)[number]) {
  return (
    <figure className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
      <blockquote className="text-sm leading-relaxed text-white/80">{body}</blockquote>
      <figcaption className="mt-3 text-xs text-white/55">
        <span className="text-white/80">{name}</span> · {role}
      </figcaption>
    </figure>
  );
}

export default function MarqueeVertical() {
  return (
    <div className="grid h-96 w-full max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2">
      <Marquee orientation="vertical" speed={24} gap={16} fade={64} className="h-full">
        {quotes.slice(0, 3).map((quote) => (
          <Quote key={quote.name} {...quote} />
        ))}
      </Marquee>
      <Marquee orientation="vertical" reverse speed={20} gap={16} fade={64} className="hidden h-full sm:flex">
        {quotes.slice(3).map((quote) => (
          <Quote key={quote.name} {...quote} />
        ))}
      </Marquee>
    </div>
  );
}
```

### Pause button

For content that keeps moving, give people a visible way to stop it with paused.

```tsx
"use client";

import * as React from "react";
import { Pause, Play } from "@/components/ui/icons";
import { Marquee } from "@/components/ui/marquee";

const regions = [
  { id: "fra1", latency: "41ms", ok: true },
  { id: "iad1", latency: "38ms", ok: true },
  { id: "sin1", latency: "112ms", ok: true },
  { id: "gru1", latency: "164ms", ok: false },
  { id: "hnd1", latency: "96ms", ok: true },
  { id: "syd1", latency: "128ms", ok: true },
];

export default function MarqueePauseControl() {
  const [paused, setPaused] = React.useState(false);

  return (
    <div className="flex w-full max-w-2xl items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] py-1.5 pl-1.5 pr-3 [--neon:var(--neon-accent,34_211_238)]">
      <button
        type="button"
        aria-label={paused ? "Play ticker" : "Pause ticker"}
        onClick={() => setPaused((value) => !value)}
        className="grid size-8 shrink-0 cursor-pointer place-items-center rounded-lg text-white/60 outline-none transition-colors hover:bg-white/[0.06] hover:text-white/90 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
      >
        {paused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
      </button>
      <Marquee paused={paused} speed={32} gap={28} fade={32} aria-label="Region latency" className="min-w-0 flex-1 py-1">
        {regions.map((region) => (
          <span key={region.id} className="flex items-center gap-2 whitespace-nowrap font-mono text-xs">
            <span
              aria-hidden
              className={
                region.ok
                  ? "size-1.5 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon))]"
                  : "size-1.5 rounded-full bg-[#fbbf24] shadow-[0_0_6px_#fbbf24]"
              }
            />
            <span className="text-white/80">{region.id}</span>
            <span className="text-white/55">{region.ok ? region.latency : `${region.latency} degraded`}</span>
          </span>
        ))}
      </Marquee>
    </div>
  );
}
```

## API reference

### Marquee

Also accepts div attributes, and the ref points at the outer element. Children are the items in one run.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Scroll sideways, or up and down. |
| `reverse` | `boolean` | `false` | Run left to right (or downward) instead. |
| `speed` | `number` | `40` | Speed in pixels per second. 0 holds it still. |
| `gap` | `number \| string` | `32` | Space between items and between copies. A number is pixels. |
| `pauseOnHover` | `boolean` | `true` | Ease to a stop while the pointer is over it. |
| `paused` | `boolean` | `false` | Hold it still, for a visible pause button. |
| `fade` | `boolean \| number` | `true` | Fade the leading and trailing edges. A number sets the fade length in pixels (default 48). |
| `color` | `NeonColor` | `inherited` | Sets --neon for glowing content inside. |

## Accessibility

- The loop is made of copies, but only one is exposed: the others are aria-hidden and inert, so screen readers read each item once and Tab visits each link once.
- Keyboard focus inside stops it at once, and if the focused item had drifted out of view or under a fade, the row glides it to the middle.
- Hover eases the row to a stop instead of freezing it mid-frame, and leaving eases it back up.
- With reduced motion there's no animation and no copies: the row becomes an ordinary scrollable strip.
- Moving content that runs for more than five seconds needs a way to pause it (WCAG 2.2.2). Hover and focus cover pointer and keyboard users; for long-running marquees, add a button wired to paused.
- The edges clip, so give items that glow some vertical padding. Avoid ids inside: the hidden copies repeat them.

## Source

`components/ui/marquee.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

export interface MarqueeProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color for neon content inside. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Scroll sideways, or up and down. A vertical marquee needs a height. */
  orientation?: "horizontal" | "vertical";
  /** Run right-to-left becomes left-to-right (or upward becomes downward). */
  reverse?: boolean;
  /** Speed in pixels per second. */
  speed?: number;
  /** Space between items and between copies. A number is pixels. */
  gap?: number | string;
  /** Ease to a stop while the pointer is over it. Keyboard focus always stops it. */
  pauseOnHover?: boolean;
  /** Hold it still, for a visible pause button. */
  paused?: boolean;
  /** Fade the leading and trailing edges. A number sets the fade length in pixels. */
  fade?: boolean | number;
}

function usePrefersReducedMotion() {
  const [reduce, setReduce] = React.useState(false);
  React.useEffect(() => {
    const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
    if (!query) return;
    const update = () => setReduce(query.matches);
    update();
    query.addEventListener?.("change", update);
    return () => query.removeEventListener?.("change", update);
  }, []);
  return reduce;
}

// Duplicates exist only to make the loop seamless: hidden from assistive
// tech, and inert so their links and buttons never take focus.
const markInert = (node: HTMLDivElement | null) => {
  if (node) node.inert = true;
};

const Marquee = React.forwardRef<HTMLDivElement, MarqueeProps>(
  (
    {
      className,
      style,
      color,
      orientation = "horizontal",
      reverse = false,
      speed = 40,
      gap = 32,
      pauseOnHover = true,
      paused = false,
      fade = true,
      children,
      onPointerEnter,
      onPointerLeave,
      onFocus,
      onBlur,
      ...props
    },
    forwardedRef,
  ) => {
    const ref = React.useRef<HTMLDivElement>(null);
    const trackRef = React.useRef<HTMLDivElement>(null);
    React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement);

    const vertical = orientation === "vertical";
    const reduce = usePrefersReducedMotion();
    const realRef = React.useRef<HTMLDivElement>(null);

    // The focusable copy sits mid-track with hidden copies on both sides, and
    // starts halfway across the viewport. Any item in it can then be brought
    // to the centre without leaving the loop's range.
    const [layout, setLayout] = React.useState({ distance: 0, start: 0, lead: 1, trail: 1 });
    const { distance, start, lead, trail } = layout;

    const animation = React.useRef<Animation | null>(null);
    const geometry = React.useRef({ distance: 0, duration: 0 });
    // Where the content sits (px along the axis), carried across rebuilds.
    const offset = React.useRef(0);
    const rate = React.useRef(paused ? 0 : 1);
    const hovered = React.useRef(false);
    const focused = React.useRef(false);
    const tween = React.useRef(0);

    React.useEffect(() => {
      const root = ref.current;
      const real = realRef.current;
      if (!root || !real || reduce || typeof ResizeObserver === "undefined") return;
      const measure = () => {
        const next = real.nextElementSibling as HTMLElement | null;
        const loop = next ? (vertical ? next.offsetTop - real.offsetTop : next.offsetLeft - real.offsetLeft) : 0;
        if (loop <= 0) return;
        const viewport = vertical ? root.clientHeight : root.clientWidth;
        const middle = viewport / 2;
        const update = {
          distance: loop,
          start: middle,
          lead: Math.max(1, Math.ceil(middle / loop)),
          trail: Math.max(1, Math.ceil((viewport - middle) / loop)),
        };
        setLayout((current) =>
          current.distance === update.distance &&
          current.start === update.start &&
          current.lead === update.lead &&
          current.trail === update.trail
            ? current
            : update,
        );
      };
      measure();
      const observer = new ResizeObserver(measure);
      observer.observe(root);
      observer.observe(real);
      return () => observer.disconnect();
    }, [vertical, reduce, gap]);

    // A compositor-run loop, rebuilt when its geometry or speed changes. It
    // resumes from the current offset, so rebuilding never makes content jump.
    React.useEffect(() => {
      const track = trackRef.current;
      if (!track || reduce || !distance || speed <= 0 || typeof track.animate !== "function") return;
      const base = start - lead * distance;
      const duration = (distance / speed) * 1000;
      const at = (value: number) => (vertical ? `translate3d(0, ${value}px, 0)` : `translate3d(${value}px, 0, 0)`);
      const loop = track.animate([{ transform: at(base) }, { transform: at(base - distance) }], {
        duration,
        iterations: Infinity,
        direction: reverse ? "reverse" : "normal",
        easing: "linear",
      });
      const progress = ((((base - offset.current) / distance) % 1) + 1) % 1;
      loop.currentTime = (reverse ? 1 - progress : progress) * duration;
      loop.playbackRate = rate.current;
      animation.current = loop;
      geometry.current = { distance, duration };
      return () => {
        const current = loop.effect?.getComputedTiming().progress;
        if (typeof current === "number") offset.current = base - distance * current;
        if (animation.current === loop) animation.current = null;
        loop.cancel();
      };
    }, [distance, start, lead, speed, reverse, vertical, reduce]);

    // Ease the playback rate toward running or stopped instead of snapping.
    const settle = React.useCallback(
      (instant = false) => {
        const target = paused || focused.current || (pauseOnHover && hovered.current) ? 0 : 1;
        cancelAnimationFrame(tween.current);
        if (instant) {
          rate.current = target;
          if (animation.current) animation.current.playbackRate = target;
          return;
        }
        let last = 0;
        const step = (now: number) => {
          const dt = last ? Math.min(Math.max(now - last, 0), 64) : 16;
          last = now;
          rate.current += (target - rate.current) * (1 - Math.exp(-dt / 120));
          if (Math.abs(target - rate.current) < 0.01) rate.current = target;
          if (animation.current) animation.current.playbackRate = rate.current;
          if (rate.current !== target) tween.current = requestAnimationFrame(step);
        };
        tween.current = requestAnimationFrame(step);
      },
      [paused, pauseOnHover],
    );

    React.useEffect(() => {
      settle();
    }, [settle]);

    React.useEffect(() => () => cancelAnimationFrame(tween.current), []);

    // Tabbing onto an item that has drifted out of view (or under the fade)
    // glides the loop until the item sits in the middle.
    const reveal = (element: HTMLElement) => {
      const root = ref.current;
      const real = realRef.current;
      const loop = animation.current;
      const { distance: length, duration } = geometry.current;
      if (!root || !real || !loop || !length || !real.contains(element)) return;
      const box = root.getBoundingClientRect();
      const item = element.getBoundingClientRect();
      const inset = fade === false ? 0 : typeof fade === "number" ? fade : 48;
      const near = vertical ? item.top - box.top : item.left - box.left;
      const far = vertical ? item.bottom - box.top : item.right - box.left;
      if (near >= inset && far <= (vertical ? box.height : box.width) - inset) return;

      const copy = real.getBoundingClientRect();
      const center = (vertical ? item.top - copy.top : item.left - copy.left) + (far - near) / 2;
      const from = loop.effect?.getComputedTiming().progress ?? 0;
      let delta = center / length - from;
      delta -= Math.round(delta);
      let begin = 0;
      const glide = (now: number) => {
        begin ||= now;
        const t = Math.min(1, (now - begin + 16) / 280);
        const progress = (((from + delta * (1 - Math.pow(1 - t, 3))) % 1) + 1) % 1;
        loop.currentTime = (reverse ? 1 - progress : progress) * duration;
        if (t < 1) tween.current = requestAnimationFrame(glide);
      };
      cancelAnimationFrame(tween.current);
      tween.current = requestAnimationFrame(glide);
    };

    const fadeLength = fade === false ? 0 : typeof fade === "number" ? fade : 48;
    const mask =
      fadeLength > 0
        ? `linear-gradient(${vertical ? "to bottom" : "to right"}, transparent, #000 ${fadeLength}px, #000 calc(100% - ${fadeLength}px), transparent)`
        : undefined;

    const copyClass = cn(
      "flex shrink-0 gap-[var(--nk-marquee-gap)]",
      vertical ? "flex-col" : "items-center",
    );

    return (
      <div
        ref={ref}
        data-state={paused ? "paused" : "running"}
        // Under reduced motion the strip scrolls by hand instead, so the keyboard needs a way in.
        tabIndex={reduce ? 0 : undefined}
        style={neonVars(color, {
          "--nk-marquee-gap": typeof gap === "number" ? `${gap}px` : gap,
          // clip, unlike hidden, can't be scrolled by focus; engines without it keep hidden.
          overflow: reduce ? undefined : "clip",
          maskImage: mask,
          WebkitMaskImage: mask,
          ...style,
        } as React.CSSProperties)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          // overflow: clip doesn't zero a flex or grid item's automatic minimum size, so do it here.
          "relative flex min-h-0 min-w-0",
          vertical ? "flex-col" : "flex-row",
          reduce
            ? vertical
              ? "overflow-y-auto [scrollbar-width:thin]"
              : "overflow-x-auto [scrollbar-width:thin]"
            : "overflow-hidden",
          reduce &&
            "outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
          className,
        )}
        onPointerEnter={(event) => {
          onPointerEnter?.(event);
          if (event.pointerType === "touch") return;
          hovered.current = true;
          settle();
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);
          hovered.current = false;
          settle();
        }}
        onFocus={(event) => {
          onFocus?.(event);
          focused.current = true;
          // Keyboard moves shouldn't animate: stop at once, then bring the item into view.
          settle(true);
          if (event.target !== event.currentTarget) reveal(event.target as HTMLElement);
        }}
        onBlur={(event) => {
          onBlur?.(event);
          if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
          focused.current = false;
          settle();
        }}
        {...props}
      >
        <div
          ref={trackRef}
          className={cn(
            "flex shrink-0 gap-[var(--nk-marquee-gap)] will-change-transform",
            vertical ? "h-max flex-col" : "w-max",
          )}
        >
          {!reduce &&
            Array.from({ length: lead }, (_, i) => (
              <div key={`lead-${i}`} ref={markInert} aria-hidden className={copyClass}>
                {children}
              </div>
            ))}
          <div
            ref={realRef}
            // Still and scrollable: start and end clear of the fades.
            style={reduce ? { [vertical ? "paddingBlock" : "paddingInline"]: fadeLength } : undefined}
            className={copyClass}
          >
            {children}
          </div>
          {!reduce &&
            Array.from({ length: trail }, (_, i) => (
              <div key={`trail-${i}`} ref={markInert} aria-hidden className={copyClass}>
                {children}
              </div>
            ))}
        </div>
      </div>
    );
  },
);
Marquee.displayName = "Marquee";

export { Marquee };
```
