{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bounce-sidebar",
  "title": "Bounce Sidebar",
  "description": "A vertical navigation list with a bouncy, spring-animated active indicator.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/bounce-sidebar.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type ComponentProps } from \"react\";\nimport Link from \"next/link\";\nimport { motion, useAnimate } from \"motion/react\";\nimport { arc } from \"motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst MotionLink = motion.create(Link);\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nexport type BounceSidebarItem = string | { label: string; href?: string };\n\nexport type BounceSidebarProps = Omit<ComponentProps<\"ul\">, \"onChange\"> & {\n  items: BounceSidebarItem[];\n  value?: number;\n  defaultValue?: number;\n  onChange?: (index: number) => void;\n  dotColor?: string;\n};\n\nexport function BounceSidebar({\n  items,\n  value,\n  defaultValue = 0,\n  onChange,\n  dotColor = \"#FC4C01\",\n  className,\n  ...props\n}: BounceSidebarProps) {\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const activeIndex = value ?? internalValue;\n\n  const [dot, animate] = useAnimate<HTMLSpanElement>();\n  const itemRefs = useRef<(HTMLLIElement | null)[]>([]);\n  const prevY = useRef<number | null>(null);\n\n  const [dotSize, setDotSize] = useState(6);\n  const [ready, setReady] = useState(false);\n  useEffect(() => {\n    const dpr = window.devicePixelRatio || 1;\n    setDotSize(Math.round(6 * dpr) / dpr);\n  }, []);\n\n  useIsomorphicLayoutEffect(() => {\n    let cancelled = false;\n    const snap = () => {\n      const el = itemRefs.current[activeIndex];\n      if (cancelled || !el || !dot.current) return;\n      const dpr = window.devicePixelRatio || 1;\n      const size = Math.round(6 * dpr) / dpr;\n      const toY =\n        Math.round((el.offsetTop + el.offsetHeight / 2 - size / 2) * dpr) / dpr;\n      animate(dot.current, { x: 0, y: toY }, { duration: 0 });\n      prevY.current = toY;\n      setReady(true);\n    };\n\n    snap();\n    const raf = requestAnimationFrame(snap);\n    document.fonts?.ready.then(snap);\n    return () => {\n      cancelled = true;\n      cancelAnimationFrame(raf);\n    };\n  }, []);\n\n  useEffect(() => {\n    const el = itemRefs.current[activeIndex];\n    if (!el || !dot.current) return;\n\n    const dpr = window.devicePixelRatio || 1;\n    const toY =\n      Math.round((el.offsetTop + el.offsetHeight / 2 - dotSize / 2) * dpr) /\n      dpr;\n\n    if (prevY.current === null) {\n      animate(dot.current, { x: 0, y: toY }, { duration: 0 });\n      prevY.current = toY;\n      return;\n    }\n\n    const fromY = prevY.current;\n    const delta = toY - fromY;\n    prevY.current = toY;\n    if (delta === 0) return;\n\n    const distance = Math.abs(delta);\n    const path = arc({\n      strength: Math.min(0.8, 14 / distance),\n      direction: delta > 0 ? \"ccw\" : \"cw\",\n    });\n\n    animate(\n      dot.current,\n      { x: 0, y: toY },\n      { duration: 0.25, ease: \"easeOut\", path },\n    );\n  }, [activeIndex, animate, dot, dotSize]);\n\n  const select = (index: number) => {\n    if (value === undefined) setInternalValue(index);\n    onChange?.(index);\n  };\n\n  return (\n    <ul\n      data-slot=\"bounce-sidebar\"\n      className={cn(\"relative flex flex-col gap-1 pl-6\", className)}\n      {...props}\n    >\n      <span\n        ref={dot}\n        aria-hidden\n        className=\"absolute left-2 top-0 rounded-full transition-opacity duration-150\"\n        style={{\n          width: dotSize,\n          height: dotSize,\n          backgroundColor: dotColor,\n          opacity: ready ? 1 : 0,\n        }}\n      />\n\n      {items.map((item, index) => {\n        const label = typeof item === \"string\" ? item : item.label;\n        const href = typeof item === \"string\" ? undefined : item.href;\n        const isActive = index === activeIndex;\n        const itemClassName = cn(\n          \"flex w-full cursor-pointer items-center rounded-lg p-1 text-left text-sm transition-colors duration-200\",\n          isActive ? \"text-foreground\" : \"text-foreground/50\",\n        );\n\n        return (\n          <li\n            key={label}\n            ref={(el) => {\n              itemRefs.current[index] = el;\n            }}\n          >\n            {href ? (\n              <MotionLink\n                href={href}\n                data-slot=\"bounce-sidebar-item\"\n                data-active={isActive}\n                onClick={() => select(index)}\n                className={itemClassName}\n              >\n                {label}\n              </MotionLink>\n            ) : (\n              <motion.button\n                type=\"button\"\n                data-slot=\"bounce-sidebar-item\"\n                data-active={isActive}\n                onClick={() => select(index)}\n                className={itemClassName}\n              >\n                {label}\n              </motion.button>\n            )}\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}