"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) => (
); } /* ——— 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 ( ); } 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 (
    {people.map((p) => (
  • ))}
); } 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) => (
step(1)} />
); }