{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "proximity-sidebar",
  "type": "registry:ui",
  "title": "Proximity Sidebar",
  "description": "Dynamic document navigation minimap with smooth spring-physics cursor proximity expansion and scroll tracking.",
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/proximity-sidebar.tsx",
      "content": "\"use client\";\n\nimport React, {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Side = \"left\" | \"right\";\nexport type SectionKind = \"title\" | \"subtitle\" | \"section\" | \"body\";\nexport type SectionLevel = 1 | 2 | 3 | 4 | 5 | 6;\n\nexport type ProximitySection = {\n  id: string;\n  label: string;\n  kind?: SectionKind;\n  level?: SectionLevel;\n};\n\ntype DashPreset = {\n  base: number;\n  bump: number;\n  thickness: number;\n  className: string;\n};\n\ntype DashProps = {\n  active: boolean;\n  color?: string;\n  maxDashWidth: number;\n  mouseY: MotionValue<number>;\n  onSelect: (id: string) => void;\n  radius: number;\n  registerDash: (id: string, node: HTMLButtonElement | null) => void;\n  section: ProximitySection;\n  sectionKind: SectionKind;\n  side: Side;\n};\n\nexport type ProximitySidebarProps = {\n  activeOffset?: number;\n  className?: string;\n  color?: string;\n  defaultValue?: string;\n  maxDashWidth?: number;\n  onChange?: (id: string) => void;\n  radius?: number;\n  sections: ProximitySection[];\n  side?: Side;\n  value?: string;\n};\n\nconst DEFAULT_RADIUS = 32;\nconst DEFAULT_MAX_DASH_WIDTH = 90;\nconst SCROLL_IDLE_RESET_DELAY = 80;\n\nconst DASH_PRESETS: Record<SectionKind, DashPreset> = {\n  title: {\n    base: 54,\n    bump: 36,\n    thickness: 1.5,\n    className: \"bg-zinc-900 dark:bg-white\",\n  },\n  subtitle: {\n    base: 40,\n    bump: 42,\n    thickness: 1.25,\n    className: \"bg-zinc-700 dark:bg-zinc-300\",\n  },\n  section: {\n    base: 26,\n    bump: 48,\n    thickness: 1,\n    className: \"bg-zinc-400 dark:bg-zinc-600\",\n  },\n  body: {\n    base: 22,\n    bump: 48,\n    thickness: 1,\n    className: \"bg-zinc-400 dark:bg-zinc-600\",\n  },\n};\n\nconst getSectionElement = (id: string) =>\n  typeof document === \"undefined\" ? null : document.getElementById(id);\n\nconst getSectionKind = (section: ProximitySection): SectionKind => {\n  if (section.kind) return section.kind;\n  if (section.level === 1) return \"title\";\n  if (section.level === 2) return \"subtitle\";\n  if (section.level === 3) return \"section\";\n  return \"body\";\n};\n\nconst getScrollParent = (element: HTMLElement): EventTarget => {\n  let parent = element.parentElement;\n\n  while (parent) {\n    const { overflowY } = window.getComputedStyle(parent);\n    if (/(auto|scroll|overlay)/.test(overflowY)) {\n      return parent;\n    }\n    parent = parent.parentElement;\n  }\n\n  return window;\n};\n\nconst Dash = ({\n  active,\n  color,\n  maxDashWidth,\n  mouseY,\n  onSelect,\n  radius,\n  registerDash,\n  section,\n  sectionKind,\n  side,\n}: DashProps) => {\n  const ref = useRef<HTMLButtonElement>(null);\n  const centerCoord = useRef<number | null>(null);\n  const shouldReduceMotion = useReducedMotion();\n\n  const preset = DASH_PRESETS[sectionKind];\n\n  useEffect(() => {\n    registerDash(section.id, ref.current);\n    return () => registerDash(section.id, null);\n  }, [registerDash, section.id]);\n\n  useEffect(() => {\n    const invalidate = () => {\n      centerCoord.current = null;\n    };\n\n    window.addEventListener(\"resize\", invalidate, { passive: true });\n    window.addEventListener(\"scroll\", invalidate, { passive: true });\n\n    return () => {\n      window.removeEventListener(\"resize\", invalidate);\n      window.removeEventListener(\"scroll\", invalidate);\n    };\n  }, []);\n\n  const targetScaleX = useTransform(mouseY, (y) => {\n    if (!ref.current || y === Infinity) {\n      return preset.base / maxDashWidth;\n    }\n\n    if (centerCoord.current === null) {\n      const rect = ref.current.getBoundingClientRect();\n      centerCoord.current = rect.top + rect.height / 2;\n    }\n\n    const dist = Math.abs(y - centerCoord.current);\n    if (dist >= radius) {\n      return preset.base / maxDashWidth;\n    }\n\n    const factor = Math.cos((dist / radius) * (Math.PI / 2));\n    const width = preset.base + preset.bump * factor;\n    return Math.min(1, width / maxDashWidth);\n  });\n\n  const springScaleX = useSpring(targetScaleX, {\n    stiffness: 350,\n    damping: 32,\n    mass: 0.6,\n  });\n\n  const scaleX = shouldReduceMotion ? targetScaleX : springScaleX;\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      data-slot=\"proximity-dash\"\n      data-active={active}\n      aria-current={active ? \"location\" : undefined}\n      aria-label={`Go to ${section.label}`}\n      title={section.label}\n      className={cn(\n        \"group relative flex h-1.5 pointer-coarse:h-6 items-center border-0 bg-transparent p-0 outline-none select-none cursor-pointer\",\n        side === \"right\" ? \"justify-end\" : \"justify-start\",\n      )}\n      style={{ width: maxDashWidth }}\n      onClick={() => onSelect(section.id)}\n    >\n      <motion.span\n        className={cn(\n          \"block rounded-full transition-colors duration-200 group-focus-visible:ring-2 group-focus-visible:ring-white group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black will-change-transform\",\n          active\n            ? color\n              ? \"\"\n              : \"bg-zinc-950 dark:bg-white shadow-[0_0_8px_rgba(0,0,0,0.3)] dark:shadow-[0_0_8px_rgba(255,255,255,0.7)]\"\n            : cn(\n                preset.className,\n                \"group-hover:bg-zinc-950 dark:group-hover:bg-zinc-200\",\n              ),\n        )}\n        style={{\n          backgroundColor: active && color ? color : undefined,\n          boxShadow: active && color ? `0 0 10px ${color}80` : undefined,\n          height: preset.thickness,\n          scaleX,\n          transformOrigin: side === \"left\" ? \"left center\" : \"right center\",\n          width: maxDashWidth,\n        }}\n      />\n    </button>\n  );\n};\n\nexport function ProximitySidebar({\n  activeOffset = 0.4,\n  className,\n  color,\n  defaultValue,\n  maxDashWidth = DEFAULT_MAX_DASH_WIDTH,\n  onChange,\n  radius = DEFAULT_RADIUS,\n  sections,\n  side = \"left\",\n  value,\n}: ProximitySidebarProps) {\n  const mouseY = useMotionValue(Infinity);\n  const shouldReduceMotion = useReducedMotion();\n  const dashRefs = useRef(new Map<string, HTMLButtonElement>());\n  const pointerInside = useRef(false);\n  const resetTimer = useRef<number | null>(null);\n\n  const [internalActiveId, setInternalActiveId] = useState<string>(\n    defaultValue ?? sections[0]?.id ?? \"\",\n  );\n\n  const activeId = value !== undefined ? value : internalActiveId;\n\n  const sectionKinds = useMemo(() => {\n    return sections.reduce<Record<string, SectionKind>>(\n      (nextKinds, section) => {\n        nextKinds[section.id] = getSectionKind(section);\n        return nextKinds;\n      },\n      {},\n    );\n  }, [sections]);\n\n  const registerDash = useCallback(\n    (id: string, node: HTMLButtonElement | null) => {\n      if (node) {\n        dashRefs.current.set(id, node);\n      } else {\n        dashRefs.current.delete(id);\n      }\n    },\n    [],\n  );\n\n  const clearPendingReset = useCallback(() => {\n    if (!resetTimer.current) return;\n    window.clearTimeout(resetTimer.current);\n    resetTimer.current = null;\n  }, []);\n\n  const setMouseToDash = useCallback(\n    (id?: string) => {\n      if (!id) {\n        mouseY.set(Infinity);\n        return;\n      }\n\n      const node = dashRefs.current.get(id);\n      if (!node) return;\n\n      const rect = node.getBoundingClientRect();\n      mouseY.set(rect.top + rect.height / 2);\n    },\n    [mouseY],\n  );\n\n  const pulseDash = useCallback(\n    (id?: string) => {\n      setMouseToDash(id);\n      clearPendingReset();\n\n      if (!id || pointerInside.current) return;\n\n      resetTimer.current = window.setTimeout(() => {\n        mouseY.set(Infinity);\n        resetTimer.current = null;\n      }, SCROLL_IDLE_RESET_DELAY);\n    },\n    [clearPendingReset, mouseY, setMouseToDash],\n  );\n\n  const selectSection = useCallback(\n    (id: string) => {\n      const element = getSectionElement(id);\n      if (element) {\n        element.scrollIntoView({\n          behavior: shouldReduceMotion ? \"auto\" : \"smooth\",\n          block: \"start\",\n        });\n        window.history.replaceState(null, \"\", `#${id}`);\n      }\n\n      if (value === undefined) {\n        setInternalActiveId(id);\n      }\n      onChange?.(id);\n      pulseDash(id);\n    },\n    [onChange, pulseDash, shouldReduceMotion, value],\n  );\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return;\n      const focusedId = sections.find(\n        (s) => dashRefs.current.get(s.id) === event.target,\n      )?.id;\n      const currentIndex = sections.findIndex(\n        (s) => s.id === (focusedId ?? activeId),\n      );\n      if (currentIndex === -1) return;\n\n      event.preventDefault();\n      const nextIndex =\n        event.key === \"ArrowDown\"\n          ? Math.min(sections.length - 1, currentIndex + 1)\n          : Math.max(0, currentIndex - 1);\n      const nextId = sections[nextIndex].id;\n      selectSection(nextId);\n      dashRefs.current.get(nextId)?.focus();\n    },\n    [activeId, sections, selectSection],\n  );\n\n  useEffect(() => () => clearPendingReset(), [clearPendingReset]);\n\n  useEffect(() => {\n    if (!sections.length) return;\n\n    let frame = 0;\n\n    const scrollParents = new Set<EventTarget>([window]);\n\n    for (const section of sections) {\n      const element = getSectionElement(section.id);\n      if (element) {\n        scrollParents.add(getScrollParent(element));\n      }\n    }\n\n    const updateActiveSection = () => {\n      frame = 0;\n\n      let primaryScrollElement: HTMLElement | null = null;\n      for (const parent of scrollParents) {\n        if (\n          parent instanceof HTMLElement &&\n          parent !== document.body &&\n          parent !== document.documentElement\n        ) {\n          primaryScrollElement = parent;\n          break;\n        }\n      }\n\n      if (primaryScrollElement) {\n        if (primaryScrollElement.scrollTop <= 15) {\n          const firstId = sections[0]?.id;\n          if (firstId) {\n            if (value === undefined) setInternalActiveId(firstId);\n            if (!pointerInside.current) pulseDash(firstId);\n          }\n          return;\n        }\n\n        if (\n          primaryScrollElement.scrollTop + primaryScrollElement.clientHeight >=\n          primaryScrollElement.scrollHeight - 15\n        ) {\n          const lastId = sections[sections.length - 1]?.id;\n          if (lastId) {\n            if (value === undefined) setInternalActiveId(lastId);\n            if (!pointerInside.current) pulseDash(lastId);\n          }\n          return;\n        }\n      } else if (typeof window !== \"undefined\") {\n        if (window.scrollY <= 15) {\n          const firstId = sections[0]?.id;\n          if (firstId) {\n            if (value === undefined) setInternalActiveId(firstId);\n            if (!pointerInside.current) pulseDash(firstId);\n          }\n          return;\n        }\n\n        if (\n          window.scrollY + window.innerHeight >=\n          document.documentElement.scrollHeight - 15\n        ) {\n          const lastId = sections[sections.length - 1]?.id;\n          if (lastId) {\n            if (value === undefined) setInternalActiveId(lastId);\n            if (!pointerInside.current) pulseDash(lastId);\n          }\n          return;\n        }\n      }\n\n      const anchorY = primaryScrollElement\n        ? primaryScrollElement.getBoundingClientRect().top + 50\n        : 60;\n\n      let nextActiveId = sections[0]?.id;\n\n      for (const section of sections) {\n        const element = getSectionElement(section.id);\n        if (!element) continue;\n\n        const rect = element.getBoundingClientRect();\n        if (rect.top <= anchorY) {\n          nextActiveId = section.id;\n        }\n      }\n\n      if (nextActiveId) {\n        if (value === undefined) {\n          setInternalActiveId(nextActiveId);\n        }\n        if (!pointerInside.current) {\n          pulseDash(nextActiveId);\n        }\n      }\n    };\n\n    const scheduleUpdate = () => {\n      if (frame) return;\n      frame = window.requestAnimationFrame(updateActiveSection);\n    };\n\n    updateActiveSection();\n\n    for (const parent of scrollParents) {\n      parent.addEventListener(\"scroll\", scheduleUpdate, { passive: true });\n    }\n\n    window.addEventListener(\"resize\", scheduleUpdate);\n\n    return () => {\n      if (frame) window.cancelAnimationFrame(frame);\n\n      for (const parent of scrollParents) {\n        parent.removeEventListener(\"scroll\", scheduleUpdate);\n      }\n\n      window.removeEventListener(\"resize\", scheduleUpdate);\n    };\n  }, [activeOffset, pulseDash, sections, value]);\n\n  return (\n    <nav\n      data-slot=\"proximity-sidebar\"\n      aria-label=\"Page sections\"\n      className={cn(\n        \"flex select-none\",\n        side === \"left\" ? \"justify-start\" : \"justify-end\",\n        className,\n      )}\n    >\n      <div\n        role=\"group\"\n        tabIndex={0}\n        aria-label=\"Proximity navigation\"\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"flex flex-col outline-none gap-1 pointer-coarse:gap-0 py-0\",\n          side === \"right\" ? \"items-end\" : \"items-start\",\n        )}\n        onPointerEnter={(event) => {\n          clearPendingReset();\n          pointerInside.current = true;\n          mouseY.set(event.clientY);\n        }}\n        onPointerMove={(event) => {\n          clearPendingReset();\n          pointerInside.current = true;\n          mouseY.set(event.clientY);\n        }}\n        onPointerLeave={() => {\n          pointerInside.current = false;\n          mouseY.set(Infinity);\n        }}\n      >\n        {sections.map((section) => (\n          <Dash\n            key={section.id}\n            active={section.id === activeId}\n            color={color}\n            maxDashWidth={maxDashWidth}\n            mouseY={mouseY}\n            onSelect={selectSection}\n            radius={radius}\n            registerDash={registerDash}\n            section={section}\n            sectionKind={sectionKinds[section.id] ?? getSectionKind(section)}\n            side={side}\n          />\n        ))}\n      </div>\n    </nav>\n  );\n}\n\nexport default ProximitySidebar;\n",
      "type": "registry:ui",
      "target": "@ui/proximity-sidebar.tsx"
    }
  ]
}
