{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radial-nav",
  "title": "Radial navigation",
  "description": "Portfolio’s original radial navigation: selected-only expanding labels, a shortest-path spring pointer, and ring and pointer emphasis during rotation.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/radial-nav/radial-nav.tsx",
      "content": "\"use client\";\n\n// Portfolio's Animate UI community radial navigation by arhamkhnz.\n// See radial-nav.LICENSE.txt for the original distribution terms.\nimport { useEffect, useRef, useState, type KeyboardEvent } from \"react\";\nimport { Menu, MousePointer2, type LucideIcon } from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotionConfig, type Variants } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type RadialNavItem = { id: number; label: string; icon: LucideIcon; angle?: number };\nexport type MenuButtonConfig = { iconSize?: number; buttonSize?: number; buttonPadding?: number };\nexport type RadialNavProps = {\n  items: RadialNavItem[];\n  size?: number;\n  buttonSize?: number;\n  menuButtonConfig?: MenuButtonConfig;\n  label?: string;\n  activeId?: number | null;\n  defaultActiveId?: number;\n  onActiveChange?: (id: number) => void;\n  collapsible?: boolean;\n  className?: string;\n};\n\nconst LABEL_VARIANTS: Variants = {\n  rest: { opacity: 0, x: 4 },\n  hover: { opacity: 1, x: 0, visibility: \"visible\", width: \"auto\" },\n  tap: { opacity: 1, x: 0, visibility: \"visible\", width: \"auto\" },\n};\n\nexport function RadialNav({ items, size = 150, buttonSize = 40, menuButtonConfig, label = \"Radial navigation\", activeId: controlledId, defaultActiveId, onActiveChange, collapsible = false, className }: RadialNavProps) {\n  const reduced = useReducedMotionConfig();\n  const [internalId, setInternalId] = useState<number | null>(defaultActiveId ?? null);\n  const [expanded, setExpanded] = useState(false);\n  const [isArrowMoving, setIsArrowMoving] = useState(false);\n  const activeId = controlledId === undefined ? internalId : controlledId;\n  const activeIndex = items.findIndex((item) => item.id === activeId);\n  const activeItem = items[activeIndex];\n  const activeAngle = activeIndex < 0 ? 0 : (activeItem.angle ?? activeIndex * 360 / items.length);\n  const rotation = useMotionValue(activeAngle + 45);\n  const mounted = useRef(false);\n  const navRef = useRef<HTMLElement>(null);\n  const toggleRef = useRef<HTMLButtonElement>(null);\n  const resolved = { iconSize: 20, buttonSize, buttonPadding: 8, ...menuButtonConfig };\n  const iconOffset = (resolved.buttonSize - resolved.iconSize) / 2 - resolved.buttonPadding - 1;\n  const ActiveIcon = activeItem?.icon ?? Menu;\n  const showRing = !collapsible || expanded;\n\n  useEffect(() => {\n    if (!mounted.current) { mounted.current = true; return; }\n    const current = rotation.get();\n    const delta = ((activeAngle + 45 - current) % 360 + 540) % 360 - 180;\n    let emphasisTimer: ReturnType<typeof setTimeout> | undefined;\n    const animation = animate(rotation, current + delta, {\n      ...(reduced ? { duration: 0 } : { type: \"spring\", stiffness: 220, damping: 26 } as const),\n      onPlay: () => {\n        if (!reduced) {\n          setIsArrowMoving(true);\n          emphasisTimer = setTimeout(() => setIsArrowMoving(false), 350);\n        }\n      },\n    });\n    return () => { animation.stop(); clearTimeout(emphasisTimer); };\n  }, [activeAngle, rotation, reduced]);\n\n  useEffect(() => {\n    if (!collapsible || !expanded) return;\n    const dismiss = (event: PointerEvent) => {\n      if (!navRef.current?.contains(event.target as Node)) setExpanded(false);\n    };\n    document.addEventListener(\"pointerdown\", dismiss);\n    return () => document.removeEventListener(\"pointerdown\", dismiss);\n  }, [collapsible, expanded]);\n\n  function select(id: number) {\n    if (controlledId === undefined) setInternalId(id);\n    onActiveChange?.(id);\n    if (collapsible) setExpanded(false);\n  }\n\n  function handleKeys(event: KeyboardEvent<HTMLElement>) {\n    if (event.key === \"Escape\" && collapsible) {\n      setExpanded(false);\n      requestAnimationFrame(() => toggleRef.current?.focus());\n      return;\n    }\n    if (![\"ArrowRight\", \"ArrowDown\", \"ArrowLeft\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const buttons = [...(navRef.current?.querySelectorAll<HTMLButtonElement>(\"[data-radial-item]\") ?? [])];\n    if (!buttons.length) return;\n    event.preventDefault();\n    const index = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    const target = event.key === \"Home\" ? 0 : event.key === \"End\" ? buttons.length - 1 :\n      (index + (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 1) + buttons.length) % buttons.length;\n    buttons[target]?.focus();\n  }\n\n  return (\n    <nav ref={navRef} aria-label={label} onKeyDown={handleKeys} className={cn(\"inline-flex max-w-full items-center justify-center p-12 font-body\", className)}>\n      <AnimatePresence mode=\"wait\" initial={false}>\n        {showRing ? (\n          <motion.div key=\"expanded\" initial={collapsible && !reduced ? { scale: 0.5, opacity: 0 } : false} animate={{ scale: 1, opacity: 1 }} exit={reduced ? { opacity: 0 } : { scale: 0.5, opacity: 0 }} transition={reduced ? { duration: 0 } : { type: \"spring\", stiffness: 280, damping: 26 }} className=\"relative flex shrink-0 items-center justify-center rounded-full\" style={{ width: size, height: size, originX: 0, originY: 0 }}>\n            <motion.div aria-hidden=\"true\" className={cn(\"pointer-events-none absolute inset-0 rounded-full border\", activeId !== null ? \"border-ink\" : \"border-dim\")} initial={{ opacity: 0.4 }} animate={{ opacity: isArrowMoving ? 1 : 0.4 }} transition={{ duration: reduced ? 0 : 0.2 }} />\n            <motion.div aria-hidden=\"true\" initial={false} style={{ rotate: rotation, originX: 0.5, originY: 0.5 }} animate={{ opacity: isArrowMoving ? 1 : 0.4 }} transition={{ duration: reduced ? 0 : 0.2 }} className={cn(\"pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\", activeId !== null ? \"text-ink\" : \"text-dim\")}>\n              <MousePointer2 className=\"size-5\" />\n            </motion.div>\n            {items.map((item, index) => {\n              const angle = item.angle ?? index * 360 / items.length;\n              const radians = (angle - 90) * Math.PI / 180;\n              const radius = size / 2 - 0.5;\n              const selected = activeId === item.id;\n              const Icon = item.icon;\n              return (\n                <div key={item.id} className=\"group absolute\" style={{ left: `calc(50% + ${Math.cos(radians) * radius}px)`, top: `calc(50% + ${Math.sin(radians) * radius}px)`, transform: \"translate(-50%, -50%)\" }}>\n                  <motion.button type=\"button\" data-radial-item aria-label={item.label} aria-current={selected ? \"location\" : undefined} aria-pressed={selected}\n                    onClick={() => select(item.id)}\n                    initial={false} animate={selected ? \"hover\" : \"rest\"}\n                    variants={{ rest: { maxWidth: resolved.buttonSize }, hover: { maxWidth: 140, transition: reduced ? { duration: 0 } : { type: \"spring\", stiffness: 200, damping: 35, delay: 0.05 } }, tap: { scale: reduced ? 1 : 0.95 } }}\n                    transition={reduced ? { duration: 0 } : { type: \"spring\", stiffness: 200, damping: 25 }}\n                    style={{ height: resolved.buttonSize, minWidth: resolved.buttonSize, padding: resolved.buttonPadding }}\n                    className={cn(\"relative flex space-x-1 items-center overflow-hidden whitespace-nowrap rounded-full border bg-card font-medium focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring\", selected ? \"border-ink text-ink\" : \"border-faint text-faint\")}>\n                    <Icon aria-hidden=\"true\" className=\"shrink-0\" style={{ height: resolved.iconSize, width: resolved.iconSize, transform: `translateX(${iconOffset}px)` }} />\n                    <motion.span variants={reduced ? { rest: { opacity: 0, width: 0 }, hover: { opacity: 1, x: 0, visibility: \"visible\", width: \"auto\" } } : LABEL_VARIANTS} transition={reduced ? { duration: 0 } : { type: \"spring\", stiffness: 200, damping: 25 }} className=\"invisible w-0 text-sm\">{item.label}</motion.span>\n                  </motion.button>\n                </div>\n              );\n            })}\n          </motion.div>\n        ) : (\n          <motion.button key=\"collapsed\" ref={toggleRef} type=\"button\" aria-expanded={false} aria-label=\"Open navigation\" onClick={() => setExpanded(true)} initial={reduced ? false : { scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={reduced ? { opacity: 0 } : { scale: 0.5, opacity: 0 }} transition={reduced ? { duration: 0 } : { type: \"spring\", stiffness: 320, damping: 24 }} className=\"inline-flex size-12 items-center justify-center rounded-full border border-ink bg-card text-ink shadow-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring\">\n            <ActiveIcon className=\"size-5\" />\n          </motion.button>\n        )}\n      </AnimatePresence>\n    </nav>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/items/radial-nav/radial-nav.LICENSE.txt",
      "content": "MIT + Commons Clause License Condition\n\nCopyright (c) 2025 Elliot Sutton\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, and distribute the Software **as part of an application, website, or product**, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\n## Commons Clause Restriction\n\nYou may use this Software, including for any commercial purpose, **so long as you do not sell or redistribute the components themselves in their original form—whether alone or in a bundle.**\n\n## No Warranty\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use for compact section navigation. Choose inline or collapsible presentation; handle navigation and scroll tracking through activeId and onActiveChange in the consuming page.",
    "origin": {
      "project": "Portfolio",
      "path": "src/components/animate-ui/components/community/radial-nav.tsx; src/components/radial-nav-wrapper.tsx"
    },
    "credits": [
      {
        "name": "Animate UI community Radial Nav by arhamkhnz",
        "url": "https://animate-ui.com/docs/components/community/radial-nav",
        "license": "MIT WITH Commons-Clause"
      }
    ]
  },
  "type": "registry:component"
}
