{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"radial-menu","type":"registry:component","title":"Radial menu","description":"One starting point, a few ways forward. Commands unfold into a small clockwise constellation.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/radial-menu.tsx","type":"registry:component","content":"// Hairline UI · radial-menu · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { useRadialMenu } from './use-radial-menu';\n\nexport type RadialMenuItem = { id: string; label: string; icon: React.ReactNode; disabled?: boolean };\nexport interface RadialMenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {\n  items: RadialMenuItem[]; label?: string; open?: boolean; defaultOpen?: boolean; disabled?: boolean;\n  onOpenChange?: (open: boolean) => void; onSelect: (id: string) => void;\n}\n\n/** Commands unfold from their source into a clockwise ring; the closed menu has no focusable hidden items. */\nexport function RadialMenu({ items, label = 'Insert', open: controlled, defaultOpen = false, disabled = false, onOpenChange, onSelect, className = '', ...props }: RadialMenuProps) {\n  const reduced = useInteractionReducedMotion();\n  const [own, setOwn] = React.useState(defaultOpen);\n  const open = !disabled && (controlled ?? own);\n  const change = (next: boolean) => { if (controlled === undefined) setOwn(next); onOpenChange?.(next); };\n  const enabled = items.flatMap((item, index) => item.disabled ? [] : [index]);\n  const menu = useRadialMenu(items.length, enabled, open, change);\n  const id = React.useId();\n  return <div {...props} ref={menu.root} className={`relative h-[284px] w-full text-foreground ${className}`}\n    onKeyDown={(event) => { if (event.key === 'Escape' && open) { event.preventDefault(); menu.close(); } }}\n    onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) menu.close(false); }}>\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute left-1/2 top-1/2 rounded-full border border-border\" style={{ width: menu.radius * 2, height: menu.radius * 2, marginLeft: -menu.radius, marginTop: -menu.radius, opacity: open ? 1 : 0, transform: open || reduced ? 'none' : 'scale(0.35)', transition: reduced ? 'none' : `transform 480ms ${EASE_OUT}, opacity 220ms ${EASE_OUT}` }} />\n    <div id={id} role=\"menu\" aria-label={label} aria-hidden={!open} inert={!open}>\n      {items.map((item, index) => {\n        const point = menu.point(index), delay = open ? index * 24 : 0;\n        return <React.Fragment key={item.id}>\n          <span aria-hidden=\"true\" className=\"pointer-events-none absolute left-1/2 top-1/2 h-px origin-left bg-border\" style={{ width: menu.radius - 22, transform: `rotate(${point.angle}deg) scaleX(${open ? 1 : 0})`, opacity: open ? 1 : 0, transition: reduced ? 'none' : `transform 400ms ${EASE_OUT} ${delay}ms, opacity 160ms ${EASE_OUT}` }} />\n          <button ref={(element) => { if (element) menu.buttons.current.set(index, element); else menu.buttons.current.delete(index); }} type=\"button\" role=\"menuitem\" disabled={item.disabled} tabIndex={open && index === enabled[0] ? 0 : -1}\n            onFocus={(event) => menu.buttons.current.forEach((button) => { button.tabIndex = button === event.currentTarget ? 0 : -1; })}\n            onKeyDown={(event) => menu.navigate(event, index)} onClick={() => { menu.close(); onSelect(item.id); }}\n            className={`absolute left-1/2 top-1/2 -ml-5 -mt-5 flex size-10 cursor-pointer items-center justify-center rounded-[14px] border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-primary disabled:cursor-default disabled:opacity-30 [&>svg]:size-[18px] ${INTERACTION_FOCUS}`}\n            style={{ opacity: open ? 1 : 0, transform: open ? `translate3d(${point.x}px,${point.y}px,0) scale(1)` : reduced ? 'none' : 'translate3d(0,0,0) scale(0.5)', transition: reduced ? 'none' : `transform 460ms ${EASE_SETTLE} ${delay}ms, opacity ${open ? 240 : 120}ms ${EASE_OUT} ${delay}ms, background-color 180ms, color 180ms`, willChange: open ? 'transform' : undefined }}>\n            <span aria-hidden=\"true\" className=\"flex [&>svg]:size-[18px]\">{item.icon}</span>\n            <span className=\"pointer-events-none absolute top-full mt-2 rounded-full bg-background px-1.5 text-[10px] font-medium leading-4 whitespace-nowrap\">{item.label}</span>\n          </button>\n        </React.Fragment>;\n      })}\n    </div>\n    <button ref={menu.trigger} type=\"button\" aria-label={label} aria-haspopup=\"menu\" aria-expanded={open} aria-controls={id} disabled={disabled || !enabled.length}\n      onClick={() => change(!open)} onKeyDown={(event) => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); change(true); } }}\n      className={`absolute left-1/2 top-1/2 -ml-6 -mt-6 flex size-12 cursor-pointer items-center justify-center rounded-full border border-primary bg-primary text-primary-foreground disabled:cursor-default disabled:opacity-40 ${INTERACTION_FOCUS}`}>\n      <svg aria-hidden=\"true\" viewBox=\"0 0 20 20\" className=\"size-5\" style={{ transform: open ? 'rotate(135deg)' : 'rotate(0deg)', transition: reduced ? 'none' : `transform 420ms ${EASE_SETTLE}` }}><path d=\"M10 4v12M4 10h12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" /></svg>\n    </button>\n  </div>;\n}\n"},{"path":"registry/use-radial-menu.ts","type":"registry:component","content":"// Hairline UI · radial-menu · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\n/** Focus follows the same clockwise order as the geometry. Resize measurements never run in a gesture. */\nexport function useRadialMenu(count: number, enabled: number[], open: boolean, onOpenChange: (open: boolean) => void) {\n  const root = React.useRef<HTMLDivElement>(null);\n  const trigger = React.useRef<HTMLButtonElement>(null);\n  const buttons = React.useRef(new Map<number, HTMLButtonElement>());\n  const focusFrame = React.useRef(0);\n  const [radius, setRadius] = React.useState(86);\n  const latest = React.useRef({ enabled, onOpenChange }); latest.current = { enabled, onOpenChange };\n  React.useEffect(() => {\n    const element = root.current;\n    if (!element) return;\n    const observer = new ResizeObserver(([entry]) => {\n      const next = Math.max(40, Math.min(90, entry.contentRect.width / 2 - 48));\n      setRadius((previous) => previous === next ? previous : next);\n    });\n    observer.observe(element); return () => observer.disconnect();\n  }, []);\n  React.useEffect(() => {\n    if (open) focusFrame.current = requestAnimationFrame(() => buttons.current.get(latest.current.enabled[0])?.focus({ preventScroll: true }));\n    return () => { cancelAnimationFrame(focusFrame.current); focusFrame.current = 0; };\n  }, [open]);\n  const close = (focus = true) => { latest.current.onOpenChange(false); if (focus) trigger.current?.focus({ preventScroll: true }); };\n  const navigate = (event: React.KeyboardEvent, index: number) => {\n    if (event.key === 'Escape') { event.preventDefault(); close(); return; }\n    const list = latest.current.enabled;\n    if (!['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;\n    event.preventDefault();\n    const at = list.indexOf(index);\n    const next = event.key === 'Home' ? list[0] : event.key === 'End' ? list.at(-1) : list[(at + (event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length];\n    if (next !== undefined) buttons.current.get(next)?.focus({ preventScroll: true });\n  };\n  return { root, trigger, buttons, radius, close, navigate,\n    point(index: number) { const angle = -Math.PI / 2 + index * Math.PI * 2 / Math.max(1, count); return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, angle: angle * 180 / Math.PI }; },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · radial-menu · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/radial-menu. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}