"use client";
import * as React from "react";
export interface InfiniteMarqueeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** Items to scroll. Each child becomes one list item. */
children: React.ReactNode;
/** Travel direction. */
direction?: "left" | "right";
/** Speed in pixels per second. */
speed?: number;
/** Space between items in px. */
gap?: number;
/** Ease to a stop while a mouse hovers the marquee. */
pauseOnHover?: boolean;
/** Fade the left and right edges. */
fade?: boolean;
/** Show a small pause / play button (recommended for long-running marquees, WCAG 2.2.2). */
controls?: boolean;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/** True when focus is inside the marquee, ignoring its own pause / play button. */
function isFocusWithin(root: HTMLElement) {
const active = document.activeElement;
return !!active && root.contains(active) && !(active instanceof HTMLElement && "marqueeControl" in active.dataset);
}
const FADE_MASK = "linear-gradient(to right, transparent, #000 12%, #000 88%, transparent)";
/**
* A seamless horizontal marquee. The item group is repeated just enough times to cover the
* viewport and the track is translated by exactly one group width with the Web Animations API,
* so the loop never jumps. It slows to a stop on hover and focus, and pauses offscreen.
*/
export function InfiniteMarquee({
children,
direction = "left",
speed = 48,
gap = 40,
pauseOnHover = true,
fade = true,
controls = false,
className = "",
style,
...props
}: InfiniteMarqueeProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const trackRef = React.useRef<HTMLDivElement>(null);
const groupRef = React.useRef<HTMLUListElement>(null);
// Horizontal position as a fraction of one group width — survives option changes without a jump.
const positionRef = React.useRef(0);
const [copies, setCopies] = React.useState(2);
const [userPaused, setUserPaused] = React.useState(false);
React.useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const group = groupRef.current;
if (reduceMotion || !root || !track || !group) return;
let animation: Animation | null = null;
let width = 0;
let rateFrame = 0;
// Restore pointer / focus state when the effect re-runs (e.g. after an option change).
let hovered = root.matches(":hover");
let focused = isFocusWithin(root);
let offscreen = false;
const pxPerMs = Math.max(speed, 1) / 1000;
// Read where the track currently is, as a fraction of a group (0 → 1 = one group to the left).
const readPosition = () => {
if (!animation || !width) return positionRef.current;
const duration = width / pxPerMs;
const progress = ((Number(animation.currentTime) || 0) % duration) / duration;
return direction === "left" ? progress : 1 - progress;
};
const easeRate = (target: number) => {
cancelAnimationFrame(rateFrame);
const step = () => {
if (!animation) return;
const next = animation.playbackRate + (target - animation.playbackRate) * 0.12;
const done = Math.abs(next - target) < 0.01;
animation.playbackRate = done ? target : next;
if (!done) rateFrame = requestAnimationFrame(step);
};
rateFrame = requestAnimationFrame(step);
};
const sync = () => {
if (!animation) return;
if (userPaused || offscreen || document.hidden) {
cancelAnimationFrame(rateFrame);
animation.pause();
return;
}
if (animation.playState !== "running") animation.play();
easeRate((pauseOnHover && hovered) || focused ? 0 : 1);
};
const build = () => {
// Sub-pixel width in layout px: divide out any ancestor scale / zoom (offsetWidth alone is rounded).
const rootRect = root.getBoundingClientRect().width;
const scale = rootRect && root.offsetWidth ? rootRect / root.offsetWidth : 1;
const groupWidth = group.getBoundingClientRect().width / scale;
if (!groupWidth) return;
// Enough copies that the visible area is always covered while the track moves one group.
const needed = Math.max(2, Math.ceil(root.clientWidth / groupWidth) + 1);
setCopies((current) => (current === needed ? current : needed));
if (animation && Math.abs(groupWidth - width) < 0.5) return;
const position = readPosition();
const rate = animation?.playbackRate ?? 1;
animation?.cancel();
width = groupWidth;
const duration = width / pxPerMs;
const frames =
direction === "left"
? [{ transform: "translate3d(0,0,0)" }, { transform: `translate3d(${-width}px,0,0)` }]
: [{ transform: `translate3d(${-width}px,0,0)` }, { transform: "translate3d(0,0,0)" }];
animation = track.animate(frames, { duration, iterations: Infinity, easing: "linear" });
animation.currentTime = (direction === "left" ? position : 1 - position) * duration;
animation.playbackRate = rate;
sync();
};
const resizeObserver = new ResizeObserver(build);
resizeObserver.observe(root);
resizeObserver.observe(group);
const intersectionObserver = new IntersectionObserver(([entry]) => {
offscreen = !entry.isIntersecting;
sync();
});
intersectionObserver.observe(root);
const onEnter = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
hovered = true;
sync();
};
const onLeave = () => {
hovered = false;
sync();
};
// Keyboard users tabbing into links inside the marquee get a still target.
const onFocusIn = () => {
focused = isFocusWithin(root);
sync();
};
const onFocusOut = (event: FocusEvent) => {
if (root.contains(event.relatedTarget as Node | null)) return;
focused = false;
sync();
};
root.addEventListener("pointerenter", onEnter);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("focusin", onFocusIn);
root.addEventListener("focusout", onFocusOut);
document.addEventListener("visibilitychange", sync);
return () => {
positionRef.current = readPosition();
cancelAnimationFrame(rateFrame);
resizeObserver.disconnect();
intersectionObserver.disconnect();
root.removeEventListener("pointerenter", onEnter);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("focusin", onFocusIn);
root.removeEventListener("focusout", onFocusOut);
document.removeEventListener("visibilitychange", sync);
animation?.cancel();
};
}, [reduceMotion, speed, direction, pauseOnHover, userPaused]);
const items = React.Children.toArray(children);
const renderGroup = (copy: number) => (
<ul
key={copy}
ref={copy === 0 ? groupRef : undefined}
// Copies exist only to fill the loop: hidden from assistive tech and removed from the tab order.
aria-hidden={copy > 0 ? true : undefined}
inert={copy > 0 ? true : undefined}
className="m-0 flex shrink-0 list-none items-center p-0"
style={{ gap, paddingRight: gap }}
>
{items.map((item, i) => (
<li key={i} className="shrink-0">
{item}
</li>
))}
</ul>
);
if (reduceMotion) {
// Static alternative: one copy in a horizontally scrollable, keyboard-focusable region.
return (
<div
tabIndex={0}
className={`overflow-x-auto focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500 ${className}`}
style={style}
{...props}
>
{renderGroup(0)}
</div>
);
}
return (
<div ref={rootRef} className={`relative ${className}`} style={style} {...props}>
<div
className="overflow-hidden"
style={{ maskImage: fade ? FADE_MASK : undefined, WebkitMaskImage: fade ? FADE_MASK : undefined }}
>
<div ref={trackRef} className="flex w-max will-change-transform">
{Array.from({ length: copies }, (_, copy) => renderGroup(copy))}
</div>
</div>
{controls && (
<button
type="button"
data-marquee-control=""
onClick={() => setUserPaused((paused) => !paused)}
aria-pressed={userPaused}
aria-label={userPaused ? "Play marquee" : "Pause marquee"}
className="absolute right-0 top-1/2 inline-flex h-7 -translate-y-1/2 items-center gap-1.5 rounded-full border border-zinc-900/10 bg-white px-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 shadow-[0_1px_2px_rgba(0,0,0,0.06)] transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
>
<svg viewBox="0 0 10 10" className="h-2 w-2" aria-hidden="true">
{userPaused ? (
<path d="M2 1l7 4-7 4z" fill="currentColor" />
) : (
<path d="M2 1h2v8H2zM6 1h2v8H6z" fill="currentColor" />
)}
</svg>
{userPaused ? "Play" : "Pause"}
</button>
)}
</div>
);
}