"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
import "./orbit-card.css";
/**
* Orbit Card
* A compact profile card. On hover, seven dots in the person's colour start
* travelling around the card's rounded border (CSS offset-path) and the border
* warms toward that colour. Ships with an endless, draggable marquee.
*/
export type OrbitPerson = {
id: string;
name: string;
role: string;
/** Unique orbit colour for this person. */
color: string;
href?: string;
};
const DOTS = [
{ duration: 4.2, delay: 0, size: 5 },
{ duration: 3.55, delay: -0.9, size: 4 },
{ duration: 4.85, delay: -1.8, size: 6 },
{ duration: 3.9, delay: -2.6, size: 4.5 },
{ duration: 5.1, delay: -3.4, size: 5.5 },
{ duration: 3.7, delay: -1.35, size: 4 },
{ duration: 4.45, delay: -2.15, size: 5 },
] as const;
export function OrbitCard({ person, decorative, trailing }: { person: OrbitPerson; decorative?: boolean; trailing?: ReactNode }) {
return (
{DOTS.map((dot, i) => (
))}
{person.name}
{trailing}
{person.role}
);
}
/* ——— Marquee ——— */
const SPEED_DESKTOP = 30; // px / s
const SPEED_MOBILE = 22;
const STEP_MS = 480;
const SWIPE_ARM_PX = 10;
const wrapModulo = (value: number, loop: number) => (loop < 8 ? 0 : ((value % loop) + loop) % loop);
const easeStep = (t: number) => 1 - (1 - t) ** 3;
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const on = () => setReduced(mq.matches);
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, []);
return reduced;
}
function PersonSet({ people, hidden }: { people: OrbitPerson[]; hidden?: boolean }) {
return (
{people.map((p, i) => (
-
))}
);
}
function Pager({ label, onClick, dir }: { label: string; onClick: () => void; dir: -1 | 1 }) {
return (
);
}
export function OrbitMarquee({ people, label = "People" }: { people: OrbitPerson[]; label?: string }) {
const reduced = usePrefersReducedMotion();
const wrapRef = useRef(null);
const trackRef = useRef(null);
const offset = useRef(0);
const loopWidth = useRef(0);
const stride = useRef(304);
const speed = useRef(SPEED_DESKTOP);
const hoverPause = useRef(false);
const visible = useRef(true);
const lastTs = useRef(0);
const stepAnim = useRef<{ from: number; to: number; start: number } | null>(null);
const pointer = useRef<{ id: number; x: number; dragging: boolean } | null>(null);
const skipClick = useRef(false);
const [repeats, setRepeats] = useState(1);
const apply = useCallback(() => {
if (trackRef.current) trackRef.current.style.transform = `translate3d(${-offset.current}px, 0, 0)`;
}, []);
const step = useCallback(
(direction: -1 | 1) => {
const loop = loopWidth.current;
if (loop < 8 || stride.current < 8) return;
let from = wrapModulo(offset.current, loop);
let to = from + direction * stride.current;
if (to < 0) {
from += loop;
to += loop;
}
offset.current = from;
apply();
stepAnim.current = { from, to, start: performance.now() };
},
[apply],
);
useLayoutEffect(() => {
if (reduced) return;
const wrap = wrapRef.current;
const firstSet = wrap?.querySelector("[data-marquee-set]");
if (!wrap || !firstSet) return;
const measure = () => {
const item = firstSet.querySelector("li");
const next = item?.nextElementSibling as HTMLElement | null;
if (item && next) stride.current = next.getBoundingClientRect().left - item.getBoundingClientRect().left;
const oneSet = firstSet.scrollWidth / Math.max(repeats, 1);
const viewWidth = wrap.getBoundingClientRect().width;
if (oneSet < 8) return;
const copies = Math.max(1, Math.ceil(viewWidth / oneSet));
speed.current = window.innerWidth < 640 ? SPEED_MOBILE : SPEED_DESKTOP;
if (copies !== repeats) return setRepeats(copies);
loopWidth.current = firstSet.scrollWidth;
offset.current = wrapModulo(offset.current, loopWidth.current);
apply();
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
ro.observe(firstSet);
return () => ro.disconnect();
}, [apply, reduced, repeats]);
useEffect(() => {
if (reduced) return;
const io = new IntersectionObserver(([e]) => (visible.current = e.isIntersecting));
if (wrapRef.current) io.observe(wrapRef.current);
let frame = 0;
const tick = (now: number) => {
const anim = stepAnim.current;
if (anim) {
const t = Math.min(1, (now - anim.start) / STEP_MS);
offset.current = anim.from + (anim.to - anim.from) * easeStep(t);
apply();
if (t >= 1) {
offset.current = wrapModulo(anim.to, loopWidth.current);
apply();
stepAnim.current = null;
lastTs.current = now;
}
} else if (!hoverPause.current && visible.current) {
if (lastTs.current) {
const dt = Math.min(32, now - lastTs.current) / 1000;
offset.current = wrapModulo(offset.current + speed.current * dt, loopWidth.current);
apply();
}
lastTs.current = now;
} else {
lastTs.current = now;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
io.disconnect();
};
}, [apply, reduced]);
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0 || (e.target as HTMLElement).closest("a, button")) return;
pointer.current = { id: e.pointerId, x: e.clientX, dragging: false };
};
const onPointerMove = (e: PointerEvent) => {
const p = pointer.current;
if (!p || p.id !== e.pointerId) return;
if (!p.dragging && Math.abs(e.clientX - p.x) > SWIPE_ARM_PX) {
p.dragging = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
};
const onPointerUp = (e: PointerEvent) => {
const p = pointer.current;
pointer.current = null;
if (!p || p.id !== e.pointerId || !p.dragging) return;
skipClick.current = true;
const dx = e.clientX - p.x;
const threshold = stride.current * 0.45;
if (dx <= -threshold) step(1);
else if (dx >= threshold) step(-1);
};
if (reduced) {
return (
);
}
const extra = Math.max(repeats - 1, 0);
return (
{
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
step(e.key === "ArrowLeft" ? -1 : 1);
}}
>
step(-1)} />
(pointer.current = null)}
onClickCapture={(e) => {
if (!skipClick.current) return;
e.preventDefault();
e.stopPropagation();
skipClick.current = false;
}}
onMouseOver={(e) => {
if ((e.target as HTMLElement).closest(".orbit-card")) hoverPause.current = true;
}}
onMouseOut={(e) => {
const next = e.relatedTarget as HTMLElement | null;
if (!next || !e.currentTarget.contains(next) || !next.closest(".orbit-card")) hoverPause.current = false;
}}
onFocus={() => (hoverPause.current = true)}
onBlur={() => (hoverPause.current = false)}
>
{Array.from({ length: extra }, (_, i) => (
))}
{Array.from({ length: repeats }, (_, i) => (
))}
step(1)} />
);
}