import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { Spring, createLoop, onReducedMotionChange, prefersReducedMotion } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import { watchLive } from '../type-kit/kit';
import '../shared/ember.css';
import './marquee.css';
export interface MarqueeProps {
/** One copy of the content; it is repeated as often as the width needs. */
children?: ReactNode;
/** Resting speed in px/s. */
speed?: number;
direction?: 'left' | 'right';
/** How strongly page scroll velocity pushes the loop (0 ignores scrolling). */
scrollFactor?: number;
/** Slow to a stop while hovered or while focus is inside. */
pauseOnHover?: boolean;
/** Width of the faded edges in px. */
fade?: number;
/** Space between copies in px. */
gap?: number;
/** Accessible name for the region. */
label?: string;
accent?: Accent;
className?: string;
style?: CSSProperties;
}
const MAX_SKEW = 6;
/** Scroll velocity (px/s) that gives full skew. */
const SKEW_AT = 2400;
/** Scroll velocity smoothing half-life in s. */
const SCROLL_HALF = 0.12;
export function Marquee({
children,
speed = 60,
direction = 'left',
scrollFactor = 1,
pauseOnHover = true,
fade = 72,
gap = 0,
label = 'Scrolling list',
accent: accentProp,
className,
style,
}: MarqueeProps) {
const accent = resolveAccent(accentProp);
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const groupRef = useRef<HTMLDivElement>(null);
const [copies, setCopies] = useState(2);
const props = useRef({ speed, direction, scrollFactor, pauseOnHover });
props.current = { speed, direction, scrollFactor, pauseOnHover };
// Enough copies to cover the width plus one, so the seam is never on screen.
useLayoutEffect(() => {
const root = rootRef.current!;
const group = groupRef.current!;
const fit = () => {
const gw = group.offsetWidth;
if (gw < 1) return;
// Capped, so a mis-sized container can never feed back into an endless row.
setCopies(Math.min(12, Math.max(2, Math.ceil(root.clientWidth / gw) + 1)));
};
const ro = new ResizeObserver(fit);
ro.observe(root);
ro.observe(group);
fit();
return () => ro.disconnect();
}, []);
useEffect(() => {
const root = rootRef.current!;
const track = trackRef.current!;
const group = groupRef.current!;
let gw = Math.max(1, group.offsetWidth);
let offset = 0;
let reduced = prefersReducedMotion();
let live = true;
let hovered = false;
let focused = false;
// Hover: a slow, near-critically damped spring so the loop decelerates instead of stopping.
const throttle = new Spring(1, { stiffness: 38, damping: 12.5 });
const skew = new Spring(0, { stiffness: 220, damping: 24 });
let scrollV = 0;
let heatShown = -1;
const scrollPos = new Map<EventTarget, { y: number; t: number }>();
const readScroll = (t: EventTarget | null) => {
if (!t || t === document || t === window) return window.scrollY;
const el = t as Element;
return el === document.documentElement || el === document.body ? window.scrollY : el.scrollTop ?? 0;
};
const onScroll = (e: Event) => {
const target = e.target === document.documentElement || e.target === document.body ? document : (e.target ?? document);
// Only scrolling that moves the marquee itself counts: the page, or an ancestor of it.
if (target !== document && !(target as Node).contains?.(root)) return;
const y = readScroll(target);
const t = e.timeStamp || performance.now();
const prev = scrollPos.get(target);
scrollPos.set(target, { y, t });
if (!prev) return;
const dt = Math.max(8, t - prev.t) / 1000;
const v = (y - prev.y) / dt;
// Keep the strongest recent reading; the tick decays it.
scrollV = Math.abs(v) > Math.abs(scrollV) ? v : scrollV * 0.6 + v * 0.4;
wake();
};
const apply = () => {
const x = ((offset % gw) + gw) % gw;
track.style.transform = `translate3d(${(-x).toFixed(2)}px,0,0) skewX(${skew.value.toFixed(3)}deg)`;
const heat = Math.min(1, Math.abs(skew.value) / MAX_SKEW);
const q = Math.round(heat * 40) / 40;
if (q !== heatShown) {
heatShown = q;
root.style.setProperty('--em-marquee-heat', String(q));
}
};
const tick = (dt: number) => {
if (!live) return false;
if (reduced) {
offset = 0;
skew.set(0);
apply();
return false;
}
const step = Math.min(dt, 0.05);
const { speed, direction, scrollFactor, pauseOnHover } = props.current;
const dir = direction === 'right' ? -1 : 1;
scrollV *= Math.pow(0.5, step / SCROLL_HALF);
if (Math.abs(scrollV) < 1) scrollV = 0;
throttle.target = pauseOnHover && (hovered || focused) ? 0 : 1;
throttle.step(step);
const push = scrollV * 0.5 * scrollFactor;
offset += (speed * throttle.value + push) * dir * step;
skew.target = Math.max(-MAX_SKEW, Math.min(MAX_SKEW, (-scrollV * scrollFactor * dir * MAX_SKEW) / SKEW_AT));
skew.step(step);
apply();
return true;
};
const loop = createLoop(tick);
const wake = () => {
if (live) loop.wake();
};
const onEnter = (e: PointerEvent) => {
if (e.pointerType === 'mouse') {
hovered = true;
wake();
}
};
const onLeave = () => {
hovered = false;
wake();
};
const onFocusIn = () => {
focused = true;
wake();
};
const onFocusOut = (e: FocusEvent) => {
if (!root.contains(e.relatedTarget as Node)) {
focused = false;
wake();
}
};
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
root.addEventListener('pointerenter', onEnter);
root.addEventListener('pointerleave', onLeave);
root.addEventListener('focusin', onFocusIn);
root.addEventListener('focusout', onFocusOut);
const ro = new ResizeObserver(() => {
gw = Math.max(1, group.offsetWidth);
apply();
});
ro.observe(group);
const offLive = watchLive(root, (l) => {
live = l;
wake();
});
const offReduced = onReducedMotionChange((r) => {
reduced = r;
root.classList.toggle('is-static', r);
wake();
});
root.classList.toggle('is-static', reduced);
apply();
wake();
return () => {
loop.stop();
ro.disconnect();
offLive();
offReduced();
document.removeEventListener('scroll', onScroll, { capture: true });
root.removeEventListener('pointerenter', onEnter);
root.removeEventListener('pointerleave', onLeave);
root.removeEventListener('focusin', onFocusIn);
root.removeEventListener('focusout', onFocusOut);
};
}, []);
return (
<div
ref={rootRef}
role="region"
aria-label={label}
className={['ember', 'em-marquee', className].filter(Boolean).join(' ')}
data-accent={accent}
style={{ ...style, ['--em-marquee-fade' as string]: `${fade}px`, ['--em-marquee-gap' as string]: `${gap}px` }}
>
<div ref={trackRef} className="em-marquee__track">
<div ref={groupRef} className="em-marquee__group">
{children}
</div>
{Array.from({ length: copies - 1 }, (_, i) => (
<div key={i} className="em-marquee__group" aria-hidden="true" inert>
{children}
</div>
))}
</div>
</div>
);
}
export default Marquee;