Skip to content

Marquee Ticker

An endless strip of words or logos you can grab: it glides to a stop under the pointer, drags with momentum, leans into fast movement and fades out at both edges.

DOM + CSS0 deps1 file · 12 KB TSXreduced-motion aware

Freeeditorialminimal
Preview

Props

24
Pause on hover
Drag to scrub
64
No runtime dependencies
"use client";

import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";

/**
 * MarqueeTicker: an endless strip of words or logos.
 *
 * A hand-written rAF loop moves the track, so it can do what a CSS keyframe
 * cannot: hover and keyboard focus brake it with an eased glide instead of a
 * hard stop, you can grab the strip and drag it, a flick keeps its momentum
 * and settles back to cruising speed, and fast movement leans the text a few
 * degrees into the direction of travel. The loop is seamless because one
 * "set" of items is measured and repeated just enough times to cover the
 * container; the offset wraps by exactly that measured width.
 *
 * Pass `items` (comma separated words) or `entries` (any nodes, e.g. inline
 * SVG wordmarks). Left and right edges fade out with a CSS mask, so it sits
 * on any background. Space pauses, arrow keys nudge. Respects
 * prefers-reduced-motion: the strip stops and becomes a scrollable row.
 * No dependencies beyond React.
 */

const ITEMS = ["Design", "Ship", "Iterate", "Repeat", "Vibe Code"];

const HEX = /^#[0-9a-fA-F]{6}$/;

function subscribeMotion(cb: () => void) {
  const m = window.matchMedia("(prefers-reduced-motion: reduce)");
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
}

/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribeMotion,
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  );
}

const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));

export type MarqueeTickerProps = {
  /** Comma-separated words. Ignored when `entries` is given. */
  items?: string;
  /** Seconds for one full pass of the item set. */
  speed?: number;
  direction?: "left" | "right";
  /** Eases the strip to a stop while the pointer is over it. */
  pauseOnHover?: boolean;
  /** Custom nodes (for example inline SVG wordmarks) instead of words. */
  entries?: ReactNode[];
  /** Grab and drag the strip, flick for momentum. */
  draggable?: boolean;
  /** Width of the edge fade in px. 0 turns it off. */
  fadeWidth?: number;
  /** Space between items in px. */
  gap?: number;
  /** Colour of the separator dot, #rrggbb. */
  accent?: string;
  /** Replace the dot between items; null removes it. */
  separator?: ReactNode;
  /** "inherit" uses the parent text colour; "light" paints light text, "dark" dark text. */
  tone?: "inherit" | "light" | "dark";
  /** Classes for each item (type size and weight). */
  itemClassName?: string;
  /** Accessible name of the region. */
  label?: string;
  className?: string;
};

export function MarqueeTicker({
  items = ITEMS.join(", "),
  speed = 24,
  direction = "left",
  pauseOnHover = true,
  entries,
  draggable = true,
  fadeWidth = 64,
  gap = 48,
  accent = "#ef4444",
  separator,
  tone = "inherit",
  itemClassName = "text-2xl font-bold tracking-tight",
  label = "Scrolling ticker",
  className = "",
}: MarqueeTickerProps) {
  const reduced = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const groupRef = useRef<HTMLUListElement>(null);
  const kick = useRef<(dv: number) => void>(() => {});
  const [copies, setCopies] = useState(3);
  const [paused, setPaused] = useState(false);

  const live = useRef({ speed, direction, pauseOnHover, draggable, paused });
  useEffect(() => {
    live.current = { speed, direction, pauseOnHover, draggable, paused };
  });

  const nodes: ReactNode[] =
    entries && entries.length > 0
      ? entries
      : items
          .split(",")
          .map((w) => w.trim())
          .filter(Boolean);

  useEffect(() => {
    if (reduced) return;
    const root = rootRef.current;
    const track = trackRef.current;
    const group = groupRef.current;
    if (!root || !track || !group) return;

    let W = 0;
    let C = 0;
    let o = 0;
    let vel = 0;
    let k = 7;
    let last = 0;
    let raf = 0;
    let visible = true;
    let hover = false;
    let focusHold = false;
    let down = false;
    let lastX = 0;
    let lastT = 0;
    let scale = 1;

    const wake = () => {
      if (raf === 0 && visible && !document.hidden) raf = requestAnimationFrame(frame);
    };

    function frame(t: number) {
      raf = 0;
      const dt = last ? Math.min((t - last) / 1000, 0.05) : 0;
      last = t;
      const p = live.current;
      const base = W > 0 ? ((p.direction === "right" ? -1 : 1) * W) / Math.max(p.speed, 1) : 0;
      if (down) {
        vel *= Math.exp(-dt * 10);
      } else {
        const held = p.paused || focusHold || (p.pauseOnHover && hover);
        vel += ((held ? 0 : base) - vel) * (1 - Math.exp(-dt * k));
        o += vel * dt;
      }
      if (W > 0) {
        const m = ((o % W) + W) % W;
        const skew = clamp(vel / 300, -6, 6);
        track!.style.transform =
          "translate3d(" + (-m).toFixed(2) + "px,0,0) skewX(" + skew.toFixed(2) + "deg)";
      }
      wake();
    }

    const ro = new ResizeObserver((list) => {
      for (const e of list) {
        const size = e.borderBoxSize?.[0]?.inlineSize ?? (e.target as HTMLElement).offsetWidth;
        if (e.target === group) W = size;
        else C = size;
      }
      if (W > 0) {
        const n = Math.max(2, Math.ceil(C / W) + 1);
        setCopies((prev) => (prev === n ? prev : n));
      }
    });
    ro.observe(group);
    ro.observe(root);

    const io = new IntersectionObserver(
      (es) => {
        visible = es.some((e) => e.isIntersecting);
        if (visible) {
          last = 0;
          wake();
        }
      },
      { rootMargin: "200px" },
    );
    io.observe(root);

    const onVis = () => {
      last = 0;
      wake();
    };
    document.addEventListener("visibilitychange", onVis);

    const layoutScale = () => {
      const r = root.getBoundingClientRect();
      return r.width > 0 ? root.offsetWidth / r.width : 1;
    };

    const onEnter = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      hover = true;
      k = 7;
    };
    const onLeave = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      hover = false;
      k = 4;
    };
    const onDown = (e: PointerEvent) => {
      if (!live.current.draggable) return;
      if (e.pointerType === "mouse" && e.button !== 0) return;
      down = true;
      scale = layoutScale();
      lastX = e.clientX;
      lastT = e.timeStamp;
      vel = 0;
      root.setPointerCapture(e.pointerId);
      root.dataset.drag = "true";
      wake();
    };
    const onMove = (e: PointerEvent) => {
      if (!down) return;
      const dx = (lastX - e.clientX) * scale;
      const dt = (e.timeStamp - lastT) / 1000;
      lastX = e.clientX;
      lastT = e.timeStamp;
      o += dx;
      if (dt > 0.001) vel = vel * 0.5 + (dx / dt) * 0.5;
      wake();
    };
    const onUp = (e: PointerEvent) => {
      if (!down) return;
      down = false;
      vel = clamp(vel, -4000, 4000);
      k = 2.4;
      delete root.dataset.drag;
      if (root.hasPointerCapture(e.pointerId)) root.releasePointerCapture(e.pointerId);
      wake();
    };
    const onFocusIn = () => {
      if (root.matches(":focus-visible")) {
        focusHold = true;
        k = 7;
      }
    };
    const onFocusOut = () => {
      focusHold = false;
      k = 4;
    };

    root.addEventListener("pointerenter", onEnter);
    root.addEventListener("pointerleave", onLeave);
    root.addEventListener("pointerdown", onDown);
    root.addEventListener("pointermove", onMove);
    root.addEventListener("pointerup", onUp);
    root.addEventListener("pointercancel", onUp);
    root.addEventListener("focusin", onFocusIn);
    root.addEventListener("focusout", onFocusOut);

    kick.current = (dv: number) => {
      vel += dv;
      k = 2.6;
      wake();
    };
    wake();

    return () => {
      cancelAnimationFrame(raf);
      raf = 0;
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      root.removeEventListener("pointerenter", onEnter);
      root.removeEventListener("pointerleave", onLeave);
      root.removeEventListener("pointerdown", onDown);
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerup", onUp);
      root.removeEventListener("pointercancel", onUp);
      root.removeEventListener("focusin", onFocusIn);
      root.removeEventListener("focusout", onFocusOut);
      kick.current = () => {};
    };
  }, [reduced]);

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.target !== e.currentTarget) return;
    if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      setPaused((p) => !p);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      kick.current(-900);
    } else if (e.key === "ArrowRight") {
      e.preventDefault();
      kick.current(900);
    }
  };

  const ink =
    tone === "light"
      ? "rgba(255,255,255,0.58)"
      : tone === "dark"
        ? "rgba(10,10,12,0.55)"
        : "color-mix(in srgb, currentColor 55%, transparent)";
  const inkHi = tone === "light" ? "#ffffff" : tone === "dark" ? "#0a0a0c" : "currentColor";
  const dot = HEX.test(accent) ? accent : "#ef4444";
  const fade = Math.max(0, fadeWidth);
  const mask =
    fade > 0
      ? "linear-gradient(90deg, transparent 0, #000 " +
        fade +
        "px, #000 calc(100% - " +
        fade +
        "px), transparent 100%)"
      : undefined;

  const rootStyle = { "--mq-c": ink, "--mq-hi": inkHi } as CSSProperties;

  const group = (hidden: boolean, i: number) => (
    <ul
      key={i}
      ref={i === 0 ? groupRef : undefined}
      role={hidden ? undefined : "list"}
      aria-hidden={hidden || undefined}
      className="m-0 flex shrink-0 list-none p-0"
    >
      {nodes.map((node, j) => (
        <li
          key={j}
          className={
            "flex shrink-0 items-center whitespace-nowrap text-[color:var(--mq-c)] transition-colors duration-200 hover:text-[color:var(--mq-hi)] " +
            itemClassName
          }
          style={{ gap: gap / 2, paddingRight: gap / 2 }}
        >
          <span className="inline-flex items-center">{node}</span>
          {separator === null ? null : separator !== undefined ? (
            <span aria-hidden="true" className="inline-flex items-center">
              {separator}
            </span>
          ) : (
            <span
              aria-hidden="true"
              className="block size-1.5 shrink-0 rounded-full"
              style={{ background: dot, boxShadow: "0 0 10px 1px " + dot + "80" }}
            />
          )}
        </li>
      ))}
    </ul>
  );

  return (
    <div
      ref={rootRef}
      role="group"
      aria-roledescription="marquee"
      aria-label={label}
      tabIndex={0}
      onKeyDown={onKeyDown}
      style={rootStyle}
      className={
        "group relative w-full select-none rounded-lg py-3 focus-visible:!rounded-xl focus-visible:!outline-2 focus-visible:!outline-offset-[-3px] focus-visible:!outline-[color:var(--mq-hi)] " +
        (draggable && !reduced ? "cursor-grab data-[drag=true]:cursor-grabbing " : "") +
        className
      }
    >
      <div
        className={
          "w-full " +
          (reduced
            ? "overflow-x-auto [scrollbar-width:none]"
            : "overflow-hidden [touch-action:pan-y]")
        }
        style={{ maskImage: mask, WebkitMaskImage: mask }}
      >
        <div ref={trackRef} className="flex w-max will-change-transform">
          {group(false, 0)}
          {reduced ? null : Array.from({ length: copies - 1 }, (_, i) => group(true, i + 1))}
        </div>
      </div>
      <span className="sr-only" aria-live="polite">
        {paused ? "Paused" : ""}
      </span>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

One set of items is rendered, measured with a ResizeObserver (fractional border-box width, so the seam never drifts) and repeated just enough times to cover the container plus one. A single requestAnimationFrame loop moves a signed offset and wraps it by exactly that measured width, so it stays seamless at any item count, font or container size. Speed is the seconds one full set takes to pass, same as before; direction flips the sign. Velocity is eased toward a target instead of switched: hovering (when pauseOnHover is on) or keyboard-focusing the strip sets the target to zero and the strip glides to a stop, and leaving eases it back up. Dragging writes the offset directly with pointer capture, tracks a smoothed release velocity, and on release hands that velocity back to the same easing, so a flick coasts and settles to cruising speed. Fast movement skews the track up to six degrees into the direction of travel. Edges fade with a CSS mask on the viewport, so there is no background colour to match. The loop sleeps when the strip is off-screen (IntersectionObserver with a 200px margin) or the tab is hidden. Pass entries to show any nodes, such as inline SVG wordmarks, instead of words. Under prefers-reduced-motion nothing moves: one set renders as a horizontally scrollable row.

Curator’s note

Rebuilt in October as a hand-driven loop so it can brake, be grabbed and be flicked, which a CSS keyframe cannot do; still zero dependencies.

Pairs well with

Pro