{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "circular-dock-menu",
  "type": "registry:ui",
  "title": "Circular Dock Menu",
  "description": "A circular dock menu with controlled state, keyboard dismissal, item callbacks, outside-click handling, and reduced-motion support.",
  "files": [
    {
      "path": "registry/sonaui/circular-dock-menu/circular-dock-menu.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  MotionConfig,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport * as m from \"motion/react-m\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface DockMenuItem {\n  /** Display label shown on the item pill. */\n  label: string;\n  /** Icon component (e.g. from lucide-react). */\n  icon: React.ComponentType<{\n    className?: string;\n    strokeWidth?: number;\n    \"aria-hidden\"?: boolean | \"true\" | \"false\";\n  }>;\n  /** Called when the item is selected. */\n  onSelect?: () => void;\n}\n\nexport interface CircularDockMenuProps {\n  /**\n   * Items rendered as arc pills when the menu is open.\n   */\n  items?: DockMenuItem[];\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state for uncontrolled usage. @default false */\n  defaultOpen?: boolean;\n  /** Called when the menu opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Spring stiffness for the open/close animation.\n   * @default 420\n   */\n  stiffness?: number;\n  /**\n   * Spring damping for the open/close animation.\n   * @default 32\n   */\n  damping?: number;\n  /** Additional CSS classes for the root container. */\n  className?: string;\n}\n\nconst DEFAULT_ITEMS: DockMenuItem[] = [];\n\nfunction getArcPosition(index: number, total: number) {\n  const progress = total <= 1 ? 0 : index / (total - 1);\n  const radius = 500 - progress * 270;\n  const angle = (-140 + progress * 36) * (Math.PI / 180);\n  return {\n    x: Math.cos(angle) * radius,\n    y: Math.sin(angle) * radius,\n    rotate: -12 + progress * 9,\n  };\n}\n\nexport default function CircularDockMenu({\n  items = DEFAULT_ITEMS,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  stiffness = 420,\n  damping = 32,\n  className,\n}: CircularDockMenuProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const isControlled = controlledOpen !== undefined;\n  const isOpen = isControlled ? controlledOpen : internalOpen;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n\n  const springConfig: Transition = useMemo(\n    () => ({ type: \"spring\", stiffness, damping, mass: 0.8 }),\n    [stiffness, damping],\n  );\n  const resolvedTransition: Transition = shouldReduceMotion\n    ? { duration: 0 }\n    : springConfig;\n\n  const setOpen = useCallback(\n    (nextOpen: boolean) => {\n      if (!isControlled) setInternalOpen(nextOpen);\n      onOpenChange?.(nextOpen);\n    },\n    [isControlled, onOpenChange],\n  );\n\n  const positionedItems = useMemo(\n    () =>\n      items.map((item, index) => ({\n        ...item,\n        ...getArcPosition(index, items.length),\n      })),\n    [items],\n  );\n\n  useEffect(() => {\n    if (!isOpen) return;\n    const handlePointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, [isOpen, setOpen]);\n\n  return (\n    <MotionConfig transition={resolvedTransition}>\n      <div\n        className={cn(\n          \"flex min-h-[520px] w-full items-center justify-center overflow-hidden px-4 py-10\",\n          className,\n        )}\n      >\n        <div ref={rootRef} className=\"relative h-[440px] max-w-[520px] w-full\">\n          <AnimatePresence initial={false}>\n            {isOpen &&\n              positionedItems.map(\n                ({ label, icon: Icon, onSelect, x, y, rotate }, index) => (\n                  <m.div\n                    key={label}\n                    className=\"absolute bottom-12 left-1/2 z-10\"\n                    initial={\n                      shouldReduceMotion\n                        ? false\n                        : {\n                            x: 0,\n                            y: 0,\n                            opacity: 0,\n                            scale: 0.56,\n                            rotate: 8,\n                            filter: \"blur(10px)\",\n                          }\n                    }\n                    animate={{\n                      x,\n                      y,\n                      opacity: 1,\n                      scale: 1,\n                      rotate,\n                      filter: \"blur(0px)\",\n                      transition: {\n                        ...(shouldReduceMotion\n                          ? { duration: 0 }\n                          : springConfig),\n                        delay: shouldReduceMotion\n                          ? 0\n                          : (items.length - index - 1) * 0.045,\n                      },\n                    }}\n                    exit={\n                      shouldReduceMotion\n                        ? { opacity: 0, transition: { duration: 0 } }\n                        : {\n                            x: 0,\n                            y: 0,\n                            opacity: 0,\n                            scale: 0.48,\n                            rotate: 10,\n                            filter: \"blur(10px)\",\n                            transition: {\n                              duration: 0.2,\n                              ease: \"easeInOut\",\n                              delay: index * 0.025,\n                            },\n                          }\n                    }\n                  >\n                    <m.button\n                      type=\"button\"\n                      className={cn(\n                        \"relative flex h-14 -translate-x-1/2 items-center gap-3 rounded-full border px-5\",\n                        \"border-border bg-background text-foreground shadow-lg\",\n                        \"cursor-pointer text-lg font-semibold whitespace-nowrap\",\n                        \"hover:border-border/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                      )}\n                      onClick={() => {\n                        onSelect?.();\n                        setOpen(false);\n                      }}\n                      onKeyDown={(event) => {\n                        if (event.key === \"Escape\") {\n                          event.preventDefault();\n                          setOpen(false);\n                          triggerRef.current?.focus();\n                        }\n                      }}\n                      whileHover={\n                        shouldReduceMotion ? {} : { scale: 1.05, zIndex: 20 }\n                      }\n                      whileTap={shouldReduceMotion ? {} : { scale: 0.96 }}\n                    >\n                      <Icon\n                        aria-hidden=\"true\"\n                        className=\"size-6 text-muted-foreground\"\n                        strokeWidth={2}\n                      />\n                      <span>{label}</span>\n                    </m.button>\n                  </m.div>\n                ),\n              )}\n          </AnimatePresence>\n\n          <m.button\n            ref={triggerRef}\n            type=\"button\"\n            aria-expanded={isOpen}\n            aria-label={isOpen ? \"Close menu\" : \"Open menu\"}\n            onClick={() => setOpen(!isOpen)}\n            className={cn(\n              \"absolute bottom-12 left-1/2 z-20 flex size-24 -translate-x-1/2 items-center justify-center rounded-full border\",\n              \"border-border bg-background text-foreground shadow-lg\",\n              \"hover:border-border/80 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n            )}\n            whileHover={shouldReduceMotion ? {} : { scale: 1.04 }}\n            whileTap={shouldReduceMotion ? {} : { scale: 0.94 }}\n          >\n            <m.span\n              className=\"block relative size-9\"\n              animate={isOpen ? \"open\" : \"closed\"}\n              initial={false}\n            >\n              <m.span\n                className=\"absolute left-0 top-1/2 h-1 w-full bg-foreground rounded-full -translate-y-1/2\"\n                variants={{ closed: { rotate: 0 }, open: { rotate: 45 } }}\n              />\n              <m.span\n                className=\"absolute left-0 top-1/2 h-1 w-full bg-foreground rounded-full -translate-y-1/2\"\n                variants={{ closed: { rotate: 90 }, open: { rotate: -45 } }}\n              />\n            </m.span>\n          </m.button>\n        </div>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "target": "components/ui/circular-dock-menu/circular-dock-menu.tsx"
    }
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}