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