"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export interface OrbitalNode {
id: string;
label: string;
/** Orbit index; nodes sharing a value share a ring (lower = closer to the centre). */
orbit: number;
icon?: React.ReactNode;
/** Shown in the centre when the node is selected. */
description?: string;
}
export interface OrbitalSkillsProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children" | "onSelect"> {
nodes: OrbitalNode[];
/** Centre heading while nothing is selected. */
title?: string;
/** Centre hint while nothing is selected. */
subtitle?: string;
/** Rotation speed of the innermost orbit in degrees per second (outer orbits are slower). */
speed?: number;
direction?: "alternate" | "clockwise" | "counterclockwise";
/** Pause while the pointer is over the diagram or focus is inside it. */
pauseOnHover?: boolean;
/** Show text labels next to icons (otherwise labels are visually hidden). */
showLabels?: boolean;
/** Draw the orbit paths. */
showRings?: boolean;
accent?: string;
defaultSelectedId?: string | null;
onSelect?: (node: OrbitalNode | null) => void;
}
const INNER = 20; // cqw — radius of the first orbit
const OUTER = 40; // cqw — radius of the last orbit
function Monogram({ label }: { label: string }) {
const letters = label
.split(/\s+/)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
return <span className="font-mono text-[0.85em] font-medium tracking-tight">{letters}</span>;
}
/**
* An orbital diagram of skills or tools. Rings rotate on a requestAnimationFrame loop that only
* writes one CSS variable per ring; labels counter-rotate to stay upright. Nodes are real buttons.
*/
export function OrbitalSkills({
nodes,
title = "Toolkit",
subtitle = "Select a node",
speed = 10,
direction = "alternate",
pauseOnHover = true,
showLabels = true,
showRings = true,
accent = "#FF7448",
defaultSelectedId = null,
onSelect,
className = "",
style,
...props
}: OrbitalSkillsProps) {
const reduceMotion = useReducedMotion() ?? false;
const groupLabelId = React.useId();
const rootRef = React.useRef<HTMLDivElement>(null);
const ringRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const buttonRefs = React.useRef(new Map<string, HTMLButtonElement>());
const pausedRef = React.useRef({ hover: false, focus: false });
const [selectedId, setSelectedId] = React.useState<string | null>(defaultSelectedId);
// Distinct orbit values, innermost first, each mapped to a radius.
const orbits = [...new Set(nodes.map((n) => n.orbit))].sort((a, b) => a - b);
const radiusOf = (rank: number) => (orbits.length === 1 ? (INNER + OUTER) / 2 : INNER + (rank / (orbits.length - 1)) * (OUTER - INNER));
const orbitCount = orbits.length;
React.useEffect(() => {
if (reduceMotion || speed === 0) return;
const root = rootRef.current;
if (!root) return;
const angles = Array.from({ length: orbitCount }, () => 0);
let frame = 0;
let running = false;
let visible = true;
let last = 0;
const loop = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
const paused = pauseOnHover && (pausedRef.current.hover || pausedRef.current.focus);
if (!paused) {
for (let i = 0; i < orbitCount; i++) {
const sign = direction === "counterclockwise" ? -1 : direction === "clockwise" ? 1 : i % 2 ? -1 : 1;
// Outer orbits travel more slowly, like a (very loose) Kepler system.
const orbitSpeed = speed * Math.sqrt(INNER / (orbitCount === 1 ? INNER : INNER + (i / (orbitCount - 1)) * (OUTER - INNER)));
angles[i] = (angles[i] + sign * orbitSpeed * dt) % 360;
ringRefs.current[i]?.style.setProperty("--orbit-angle", `${angles[i].toFixed(3)}deg`);
}
}
frame = requestAnimationFrame(loop);
};
const start = () => {
if (running || !visible || document.hidden) return;
running = true;
last = 0;
frame = requestAnimationFrame(loop);
};
const stop = () => {
running = false;
cancelAnimationFrame(frame);
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) start();
else stop();
});
io.observe(root);
const onVisibility = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVisibility);
start();
return () => {
stop();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [reduceMotion, speed, direction, pauseOnHover, orbitCount]);
const selected = nodes.find((n) => n.id === selectedId) ?? null;
const select = (node: OrbitalNode | null) => {
setSelectedId(node?.id ?? null);
onSelect?.(node);
};
// Arrow keys move focus between nodes in orbit order; Escape clears the selection.
const ordered = [...nodes].sort((a, b) => a.orbit - b.orbit);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape" && selected) {
event.preventDefault();
select(null);
return;
}
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!step || !ordered.length) return;
const currentId = (event.target as HTMLElement).dataset?.nodeId;
const i = ordered.findIndex((n) => n.id === currentId);
if (i < 0) return;
event.preventDefault();
const next = ordered[(i + step + ordered.length) % ordered.length];
buttonRefs.current.get(next.id)?.focus();
};
return (
<div
ref={rootRef}
className={`relative mx-auto aspect-square w-full [container-type:inline-size] ${className}`}
style={{ ["--os-accent" as string]: accent, ...style }}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") pausedRef.current.hover = true;
}}
onPointerLeave={() => {
pausedRef.current.hover = false;
}}
onFocus={() => {
pausedRef.current.focus = true;
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) pausedRef.current.focus = false;
}}
onKeyDown={handleKeyDown}
{...props}
>
{/* Centre: selection details, announced politely. */}
<div
className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-center shadow-[0_20px_60px_-30px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900"
style={{ width: "30cqw", height: "30cqw", backgroundImage: "radial-gradient(circle at 50% 120%, color-mix(in oklab, var(--os-accent) 22%, transparent), transparent 70%)" }}
>
<div aria-live="polite" aria-atomic="true" className="w-full px-[3cqw]">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={selected?.id ?? "__idle"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(4px)" }}
transition={{ duration: reduceMotion ? 0.01 : 0.22 }}
className="flex flex-col items-center"
>
{selected ? (
<>
<span className="font-mono uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400" style={{ fontSize: "clamp(8px, 1.9cqw, 10px)" }}>
Orbit {orbits.indexOf(selected.orbit) + 1}
</span>
<span className="mt-[0.6cqw] font-semibold leading-tight tracking-[-0.03em] text-zinc-950 dark:text-zinc-50" style={{ fontSize: "clamp(12px, 3.6cqw, 22px)" }}>
{selected.label}
</span>
{selected.description ? (
<span className="mt-[0.8cqw] leading-snug text-zinc-600 dark:text-zinc-400" style={{ fontSize: "clamp(9px, 2cqw, 12px)" }}>
{selected.description}
</span>
) : null}
</>
) : (
<>
<span className="font-semibold leading-tight tracking-[-0.03em] text-zinc-950 dark:text-zinc-50" style={{ fontSize: "clamp(13px, 4cqw, 24px)" }}>
{title}
</span>
<span className="mt-[0.8cqw] font-mono uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400" style={{ fontSize: "clamp(8px, 1.9cqw, 10px)" }}>
{subtitle}
</span>
</>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
<span id={groupLabelId} className="sr-only">
{title}
</span>
<div role="group" aria-labelledby={groupLabelId} className="absolute inset-0">
{orbits.map((orbit, rank) => {
const r = radiusOf(rank);
const members = nodes.filter((n) => n.orbit === orbit);
// Stagger each ring's starting angle so nodes on neighbouring orbits don't line up.
const offset = rank * 37;
return (
<div
key={orbit}
ref={(el) => {
ringRefs.current[rank] = el;
}}
className="absolute left-1/2 top-1/2"
style={{
width: `${r * 2}cqw`,
height: `${r * 2}cqw`,
transform: "translate(-50%, -50%) rotate(var(--orbit-angle))",
["--orbit-angle" as string]: "0deg",
}}
>
{showRings ? (
<>
<span aria-hidden="true" className="absolute inset-0 rounded-full border border-dashed border-zinc-900/15 dark:border-white/15" />
<span aria-hidden="true" className="absolute left-1/2 top-0 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--os-accent)] opacity-70" />
</>
) : null}
{members.map((node, i) => {
const angle = offset + (i / members.length) * 360;
const isSelected = node.id === selectedId;
return (
<div
key={node.id}
className="absolute left-1/2 top-1/2 size-0"
style={{
transform: `rotate(${angle}deg) translateX(${r}cqw) rotate(${-angle}deg) rotate(calc(-1 * var(--orbit-angle)))`,
}}
>
<button
ref={(el) => {
if (el) buttonRefs.current.set(node.id, el);
else buttonRefs.current.delete(node.id);
}}
type="button"
data-node-id={node.id}
aria-pressed={isSelected}
aria-label={showLabels ? undefined : node.label}
title={showLabels ? undefined : node.label}
onClick={() => select(isSelected ? null : node)}
className={`group/node absolute left-0 top-0 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-[0.45em] whitespace-nowrap rounded-full border py-[0.3em] pl-[0.3em] text-zinc-800 transition-[border-color,background-color,box-shadow] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--os-accent)] dark:text-zinc-100 ${
showLabels ? "pr-[0.8em]" : "pr-[0.3em]"
} ${
isSelected
? "border-[var(--os-accent)] bg-white shadow-[0_0_0_3px_color-mix(in_oklab,var(--os-accent)_25%,transparent)] dark:bg-zinc-900"
: "border-zinc-900/10 bg-white/90 hover:border-zinc-900/35 dark:border-white/10 dark:bg-zinc-900/90 dark:hover:border-white/35"
}`}
style={{ fontSize: "clamp(10px, 2.3cqw, 13px)" }}
>
<span
aria-hidden="true"
className={`inline-flex size-[2em] items-center justify-center rounded-full transition-colors [&>svg]:size-[1.15em] ${
isSelected ? "bg-[var(--os-accent)] text-zinc-950" : "bg-zinc-900/[0.06] dark:bg-white/[0.08]"
}`}
>
{node.icon ?? <Monogram label={node.label} />}
</span>
{showLabels ? <span className="font-medium tracking-tight">{node.label}</span> : null}
</button>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}