"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export interface RadialMenuItem {
/** Stable, unique id. */
id: string;
/** Accessible name and tooltip text. */
label: string;
/** Icon shown inside the item (decorative; the label names the item). */
icon: React.ReactNode;
/** Runs when the item is chosen. */
onSelect?: () => void;
disabled?: boolean;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface RadialMenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
items: RadialMenuItem[];
/** "button" opens from a central trigger; "contextmenu" opens at the pointer on right-click inside `children`. */
trigger?: "button" | "contextmenu";
/** Distance from the centre to each item, in px. */
radius?: number;
/** Angle of the first item in degrees (0 = right, -90 = up). Items continue clockwise. */
startAngle?: number;
/** Sweep of the fan in degrees. 360 spreads items evenly around a full circle. */
arc?: number;
/** Diameter of each item in px. */
itemSize?: number;
/** Show labels for the active item only, for every item, or never (labels stay accessible). */
labels?: "active" | "always" | "none";
/** Delay between items as they fan out, in ms. */
stagger?: number;
/** Spring used to fan items out. */
spring?: Spring;
/** Accent for the trigger, active item and focus ring. */
accent?: string;
/** Accessible name of the trigger and the menu. */
label?: string;
/** Start open (uncontrolled). Focus is not moved on mount. */
defaultOpen?: boolean;
/** Called after any item is chosen. */
onSelect?: (item: RadialMenuItem) => void;
onOpenChange?: (open: boolean) => void;
/** Target area content for `trigger="contextmenu"`. */
children?: React.ReactNode;
}
const toRad = (deg: number) => (deg * Math.PI) / 180;
/** Smallest absolute difference between two angles, in degrees. */
const angleDistance = (a: number, b: number) => Math.abs(((((a - b) % 360) + 540) % 360) - 180);
/**
* A circular menu: items fan out around a trigger (or the pointer, on right-click) with a staggered
* spring. Implements the menu button pattern with roving focus; the pointer's direction highlights
* the item it points at.
*/
export function RadialMenu({
items,
trigger = "button",
radius = 96,
startAngle = -90,
arc = 360,
itemSize = 44,
labels = "active",
stagger = 35,
spring = { stiffness: 420, damping: 26, mass: 0.8 },
accent = "#FF7448",
label = "Actions",
defaultOpen = false,
onSelect,
onOpenChange,
className = "",
style,
children,
...props
}: RadialMenuProps) {
const uid = React.useId();
const menuId = `${uid}-menu`;
const hintId = `${uid}-hint`;
const reduceMotion = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const centerRef = React.useRef<HTMLDivElement>(null);
const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
/** Which item to focus after the next open (set only by user interaction). */
const focusOnOpenRef = React.useRef<number | null>(null);
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const [active, setActive] = React.useState<number | null>(null);
// Centre of the menu inside the root, used by the contextmenu trigger.
const [origin, setOrigin] = React.useState<{ x: number; y: number } | null>(null);
const isContext = trigger === "contextmenu";
const count = items.length;
const full = Math.abs(arc) >= 360;
const step = count <= 1 ? 0 : full ? arc / count : arc / (count - 1);
const angles = items.map((_, i) => startAngle + i * step);
const setOpen = (next: boolean) => {
setIsOpen(next);
if (!next) setActive(null);
onOpenChange?.(next);
};
const open = (focusIndex: number) => {
focusOnOpenRef.current = focusIndex;
setOpen(true);
};
const close = (returnFocus: boolean) => {
setOpen(false);
if (returnFocus) (isContext ? rootRef.current : triggerRef.current)?.focus({ preventScroll: true });
};
const choose = (item: RadialMenuItem) => {
if (item.disabled) return;
item.onSelect?.();
onSelect?.(item);
close(true);
};
// Move focus into the menu once items are mounted (only when opened by the user).
React.useEffect(() => {
if (!isOpen || focusOnOpenRef.current === null) return;
const index = focusOnOpenRef.current;
focusOnOpenRef.current = null;
itemRefs.current[index]?.focus({ preventScroll: true });
}, [isOpen]);
const pointerFocus = React.useEffectEvent((event: PointerEvent) => {
if (event.pointerType === "touch") return;
const center = centerRef.current;
if (!center) return;
const rect = center.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
const distance = Math.hypot(dx, dy);
// Ignore the dead zone around the centre and pointers far away from the ring.
if (distance < itemSize * 0.6 || distance > radius + itemSize * 2.5) return;
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
let best = -1;
let bestDistance = Infinity;
angles.forEach((a, i) => {
const d = angleDistance(angle, a);
if (d < bestDistance && !items[i].disabled) {
best = i;
bestDistance = d;
}
});
// Outside a partial arc, only highlight when the pointer is reasonably close to an item.
if (best === -1 || (!full && bestDistance > Math.max(Math.abs(step) / 2, 24))) return;
if (best !== active) itemRefs.current[best]?.focus({ preventScroll: true });
});
const outsidePointer = React.useEffectEvent((event: PointerEvent) => {
const target = event.target as Element | null;
// Presses on the menu itself or on the trigger (which toggles) keep it open.
if (target?.closest?.(`[data-radial-menu="${uid}"]`) || triggerRef.current?.contains(target)) return;
close(false);
});
// While open: the pointer's direction highlights items, and pressing outside closes.
React.useEffect(() => {
if (!isOpen) return;
const onMove = (event: PointerEvent) => pointerFocus(event);
const onDown = (event: PointerEvent) => outsidePointer(event);
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerdown", onDown);
return () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerdown", onDown);
};
}, [isOpen]);
/* --------------------------------- keyboard -------------------------------- */
const moveFocus = (from: number, delta: 1 | -1) => {
for (let i = 1; i <= count; i++) {
const next = (from + delta * i + count) % count;
if (!items[next].disabled) return next;
}
return from;
};
const firstEnabled = Math.max(0, items.findIndex((item) => !item.disabled));
const lastEnabled = (() => {
for (let i = count - 1; i >= 0; i--) if (!items[i].disabled) return i;
return 0;
})();
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const current = active ?? firstEnabled;
let next: number | null = null;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = moveFocus(current, 1);
break;
case "ArrowLeft":
case "ArrowUp":
next = moveFocus(current, -1);
break;
case "Home":
next = firstEnabled;
break;
case "End":
next = lastEnabled;
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
close(true);
return;
case "Tab":
// Leaving the menu closes it; focus continues naturally.
setOpen(false);
return;
default:
return;
}
event.preventDefault();
itemRefs.current[next]?.focus({ preventScroll: true });
};
const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
open(event.key === "ArrowDown" ? firstEnabled : lastEnabled);
}
};
/* ------------------------------ context menu ------------------------------ */
const openAt = (clientX: number | null, clientY: number | null) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
// Convert from screen px to layout px in case an ancestor is scaled.
const scale = root.offsetWidth ? rect.width / root.offsetWidth : 1;
const w = root.offsetWidth;
const h = root.offsetHeight;
let x = clientX === null ? w / 2 : (clientX - rect.left) / scale;
let y = clientY === null ? h / 2 : (clientY - rect.top) / scale;
// Keep the ring inside the target area when it is large enough.
const reach = radius + itemSize / 2 + (labels === "always" ? 30 : 4);
if (w > reach * 2) x = Math.min(w - reach, Math.max(reach, x));
if (h > reach * 2) y = Math.min(h - reach, Math.max(reach, y));
setOrigin({ x, y });
open(firstEnabled);
};
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
if (!isContext) return;
// Ignore right-clicks that land on the open menu itself.
if ((event.target as HTMLElement).closest(`[data-radial-menu="${uid}"]`)) {
event.preventDefault();
return;
}
event.preventDefault();
// Keyboard-invoked context menus report (0, 0): open at the centre instead.
const fromKeyboard = event.clientX === 0 && event.clientY === 0;
openAt(fromKeyboard ? null : event.clientX, fromKeyboard ? null : event.clientY);
};
const handleTargetKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isContext || event.target !== event.currentTarget) return;
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.preventDefault();
openAt(null, null);
}
};
/* --------------------------------- render --------------------------------- */
const springTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const center = isContext ? origin : null;
const labelPlacement = (angle: number) => {
const cos = Math.cos(toRad(angle));
const sin = Math.sin(toRad(angle));
const d = radius + itemSize / 2 + 10;
return {
left: cos * d,
top: sin * d,
translate: `${cos > 0.35 ? "0%" : cos < -0.35 ? "-100%" : "-50%"} ${sin > 0.35 ? "0%" : sin < -0.35 ? "-100%" : "-50%"}`,
};
};
const menu = (
<AnimatePresence>
{isOpen && (
<div
key="menu"
id={menuId}
role="menu"
aria-label={label}
data-radial-menu={uid}
onKeyDown={handleMenuKeyDown}
className="absolute z-20 h-0 w-0"
style={center ? { left: center.x, top: center.y } : { left: "50%", top: "50%" }}
>
{/* Decorative guide ring. */}
<motion.span
aria-hidden="true"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduceMotion ? 1 : 0.6, transition: { duration: reduceMotion ? 0 : 0.16 } }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="pointer-events-none absolute rounded-full border border-dashed border-zinc-900/15 dark:border-white/15"
style={{ width: radius * 2, height: radius * 2, left: -radius, top: -radius }}
/>
{items.map((item, i) => {
const angle = angles[i];
const x = Math.cos(toRad(angle)) * radius;
const y = Math.sin(toRad(angle)) * radius;
const isActive = active === i;
const showLabel = labels === "always" || (labels === "active" && isActive);
const place = labelPlacement(angle);
return (
<React.Fragment key={item.id}>
<motion.button
ref={(node) => {
itemRefs.current[i] = node;
}}
type="button"
role="menuitem"
aria-label={item.label}
aria-disabled={item.disabled || undefined}
tabIndex={isActive || (active === null && i === firstEnabled) ? 0 : -1}
onFocus={() => setActive(i)}
onClick={() => choose(item)}
initial={reduceMotion ? { x, y, opacity: 0 } : { x: 0, y: 0, scale: 0.3, opacity: 0 }}
animate={{ x, y, scale: isActive && !reduceMotion ? 1.1 : 1, opacity: 1 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { x: 0, y: 0, scale: 0.3, opacity: 0, transition: { duration: 0.16, delay: ((count - 1 - i) * stagger) / 2000 } }
}
transition={{
x: { ...springTransition, delay: reduceMotion ? 0 : (i * stagger) / 1000 },
y: { ...springTransition, delay: reduceMotion ? 0 : (i * stagger) / 1000 },
scale: springTransition,
opacity: { duration: reduceMotion ? 0 : 0.18, delay: reduceMotion ? 0 : (i * stagger) / 1000 },
}}
className={`absolute flex items-center justify-center rounded-full border shadow-[0_8px_24px_-12px_rgba(0,0,0,0.35)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--rm-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white aria-disabled:cursor-not-allowed aria-disabled:opacity-40 dark:focus-visible:ring-offset-zinc-950 [&_svg]:h-[18px] [&_svg]:w-[18px] ${
isActive
? "border-transparent bg-[var(--rm-accent)] text-zinc-950"
: "border-zinc-900/10 bg-white text-zinc-700 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-200"
}`}
style={{ width: itemSize, height: itemSize, left: -itemSize / 2, top: -itemSize / 2 }}
>
<span aria-hidden="true" className="flex">
{item.icon}
</span>
</motion.button>
<AnimatePresence>
{showLabel && (
<motion.span
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: reduceMotion ? 0 : 0.15, delay: labels === "always" && !reduceMotion ? 0.15 + (i * stagger) / 1000 : 0 } }}
exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.1 } }}
className="pointer-events-none absolute whitespace-nowrap rounded-md border border-zinc-900/10 bg-white px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 shadow-[0_4px_12px_-6px_rgba(0,0,0,0.25)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300"
style={place}
>
{item.label}
</motion.span>
)}
</AnimatePresence>
</React.Fragment>
);
})}
</div>
)}
</AnimatePresence>
);
const accentStyle = { ["--rm-accent" as string]: accent, ...style };
if (isContext) {
return (
<div
ref={rootRef}
tabIndex={0}
role="group"
aria-label={label}
aria-describedby={hintId}
onContextMenu={handleContextMenu}
onKeyDown={handleTargetKeyDown}
className={`relative outline-none focus-visible:ring-2 focus-visible:ring-[var(--rm-accent)] ${className}`}
style={accentStyle}
{...props}
>
{children}
<span id={hintId} className="sr-only">
Right-click, press Shift+F10 or the Menu key to open {label.toLowerCase()}.
</span>
{center && (
<div ref={centerRef} aria-hidden="true" className="pointer-events-none absolute" style={{ left: center.x, top: center.y }}>
<AnimatePresence>
{isOpen && (
<motion.span
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.2 }}
className="absolute -left-1.5 -top-1.5 h-3 w-3 rounded-full border-2 border-[var(--rm-accent)] bg-white dark:bg-zinc-950"
/>
)}
</AnimatePresence>
</div>
)}
{menu}
</div>
);
}
return (
<div ref={rootRef} className={`relative inline-flex ${className}`} style={accentStyle} {...props}>
<div ref={centerRef} className="relative">
<button
ref={triggerRef}
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={isOpen ? menuId : undefined}
aria-label={label}
onClick={() => (isOpen ? close(false) : open(firstEnabled))}
onKeyDown={handleTriggerKeyDown}
className="relative z-10 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--rm-accent)] text-zinc-950 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.45)] transition-transform duration-200 hover:scale-[1.04] active:scale-95 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--rm-accent)] motion-reduce:transition-none"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className={`h-5 w-5 transition-transform duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none ${isOpen ? "rotate-45" : ""}`}
>
<path d="M10 4v12M4 10h12" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
</button>
</div>
{menu}
</div>
);
}