{
  "slug": "hook-sidebar",
  "name": "Hook Sidebar",
  "description": "Collapsible navigation sidebar with animated traveling spring rail and curved elbow hook.",
  "summary": "A precision navigation rail engineered for documentation portals, administrative sidebars, and hierarchical application navigators. Features a continuous vertical guide rail with a dynamic traveling SVG elbow hook that smoothly tracks the active or hovered navigation item via real-time DOM geometry measurement (offsetTop + offsetHeight / 2) and high-frequency ResizeObserver listeners.",
  "category": "navigation",
  "tags": [
    "sidebar",
    "navigation",
    "hook",
    "rail",
    "spring",
    "a11y"
  ],
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "motion",
    "next"
  ],
  "version": "1.0.0",
  "createdDate": "2026-09-20",
  "updatedDate": "2026-09-20",
  "interactive": true,
  "supportsColor": true,
  "highlights": [
    "Continuous SVG vector path interpolation (M0.5 0a6 6 0 0 0 6 6H12) with 6px corner curve",
    "Real-time DOM element measuring with ResizeObserver and requestAnimationFrame",
    "Dual rails: a spring-animated hover and focus preview rail and a persistent active indicator",
    "Active item follows Next.js usePathname() for exact href matches; \"#\" hrefs only update the selection",
    "Integrated prefers-reduced-motion fallback bypassing physics for instant transitions"
  ],
  "anatomy": [
    "<nav> (Root navigation wrapper with data-slot and customizable ARIA label)",
    "<Rail (Hover)> (Preview rail following pointer and focus; zinc-400 light / zinc-600 dark, dashed when dashed is set)",
    "<Rail (Active)> (Accent-colored solid/dashed traveling indicator with SVG elbow curve)",
    "<HookSidebarItem> (Semantic Next.js <Link> or <button> with active state markers)"
  ],
  "physics": {
    "engine": "Motion Spring Dynamics",
    "description": "Damped harmonic oscillator physics calculating real-time traveling rail distance and SVG hook translation with zero overshoot oscillation.",
    "parameters": [
      {
        "label": "Spring Stiffness",
        "value": "420"
      },
      {
        "label": "Damping Factor",
        "value": "34"
      },
      {
        "label": "Mass Coefficient",
        "value": "0.7"
      },
      {
        "label": "Elbow Curve Radius",
        "value": "6px corner"
      },
      {
        "label": "Dash Pattern Pitch",
        "value": "4px repeating gradient"
      },
      {
        "label": "Reduced Motion",
        "value": "0ms / Instant duration"
      }
    ]
  },
  "accessibility": {
    "role": "navigation",
    "aria": "aria-label on nav container, aria-current='page' on active Next.js links, aria-current='true' on active buttons, and aria-hidden='true' on decorative motion rails.",
    "reducedMotion": "Hook and rail transitions immediately collapse to 0s duration when prefers-reduced-motion is detected.",
    "keyboard": [
      {
        "key": "Tab / Shift+Tab",
        "description": "Focus next / previous navigation item with automatic focus indicator rail tracking."
      },
      {
        "key": "Enter / Space",
        "description": "Enter activates the focused link or button. Space activates button items only."
      }
    ]
  },
  "guidelines": {
    "recommended": [
      "Primary left navigation for technical documentation portals and knowledge bases",
      "Multi-step settings dashboards and administrative account consoles",
      "Hierarchical section navigators in rich data web applications"
    ],
    "bestPractices": [
      "Keep top-level items between 3 and 10 for optimal vertical rail travel ergonomics",
      "Ensure high-contrast accent colors (#FC4C01 or similar) against dark container surfaces",
      "Give each item a unique href so route matching selects the right item"
    ]
  },
  "props": [
    {
      "name": "items",
      "type": "HookSidebarItem[]",
      "required": true,
      "description": "Array of navigation items as strings or objects containing label, optional href, and custom onClick."
    },
    {
      "name": "label",
      "type": "string",
      "defaultValue": "undefined",
      "description": "Uppercase category or section label rendered above navigation items."
    },
    {
      "name": "value",
      "type": "number",
      "defaultValue": "undefined",
      "description": "Controlled active item index. Overrides internal state and pathname detection."
    },
    {
      "name": "defaultValue",
      "type": "number",
      "defaultValue": "0",
      "description": "Initial item index to highlight when rendered in uncontrolled mode."
    },
    {
      "name": "onChange",
      "type": "(index: number) => void",
      "defaultValue": "undefined",
      "description": "Callback triggered whenever the user clicks or navigates to a new item."
    },
    {
      "name": "color",
      "type": "string",
      "defaultValue": "\"#FC4C01\"",
      "description": "Active accent rail and hook color."
    },
    {
      "name": "dashed",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Render repeating dashed guide pattern along the rail."
    },
    {
      "name": "className",
      "type": "string",
      "defaultValue": "undefined",
      "description": "Additional CSS classes passed to the outer <nav> element."
    }
  ],
  "usage": "import { HookSidebar } from \"@/components/ui/hook-sidebar\";\n\nconst items = [\n  { label: \"Introduction\", href: \"/docs\" },\n  { label: \"Installation\", href: \"/docs/installation\" },\n  { label: \"Theming\", href: \"/docs/theming\" },\n];\n\nexport function HookSidebarDemo() {\n  return <HookSidebar label=\"Documentation\" items={items} />;\n}",
  "files": [
    {
      "name": "hook-sidebar.tsx",
      "path": "registry/ui/hook-sidebar.tsx",
      "code": "\"use client\";\n\nimport { useEffect, useRef, useState, type ComponentProps } from \"react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst CORNER = 6;\nconst DASH =\n  \"repeating-linear-gradient(to top, transparent 0 2px, currentColor 2px 4px)\";\n\nexport type HookSidebarItem =\n  | string\n  | {\n      label: string;\n      href?: string;\n      onClick?: (e: React.MouseEvent<HTMLElement>) => void;\n    };\n\nexport type HookSidebarProps = Omit<ComponentProps<\"nav\">, \"onChange\"> & {\n  items: HookSidebarItem[];\n  label?: string;\n  value?: number;\n  defaultValue?: number;\n  onChange?: (index: number) => void;\n  color?: string;\n  dashed?: boolean;\n};\n\nconst hrefOf = (item: HookSidebarItem) =>\n  typeof item === \"string\" ? undefined : item.href;\n\nconst labelOf = (item: HookSidebarItem) =>\n  typeof item === \"string\" ? item : item.label;\n\nconst Rail = ({\n  from = 0,\n  y,\n  visible,\n  color,\n  dashed,\n  className,\n}: {\n  from?: number;\n  y: number | null;\n  visible: boolean;\n  color?: string;\n  dashed: boolean;\n  className?: string;\n}) => {\n  const reduced = useReducedMotion();\n  const travel = reduced\n    ? { duration: 0 }\n    : { type: \"spring\" as const, stiffness: 420, damping: 34, mass: 0.7 };\n\n  return (\n    <motion.span\n      aria-hidden\n      initial={false}\n      style={{ color, transition: \"color 0.25s ease\" }}\n      animate={{ opacity: visible && y !== null ? 1 : 0 }}\n      transition={reduced ? { duration: 0 } : { duration: 0.2 }}\n      className={cn(\"pointer-events-none absolute inset-0\", className)}\n    >\n      <motion.span\n        initial={false}\n        animate={{ top: from, height: Math.max(0, (y ?? 0) - CORNER - from) }}\n        transition={travel}\n        style={\n          dashed\n            ? { backgroundImage: DASH }\n            : { backgroundColor: \"currentColor\" }\n        }\n        className=\"absolute left-0.5 w-px\"\n      />\n      <motion.svg\n        initial={false}\n        animate={{ top: (y ?? 0) - CORNER }}\n        transition={travel}\n        width=\"12\"\n        height=\"7\"\n        viewBox=\"0 0 12 7\"\n        fill=\"none\"\n        className=\"absolute left-0.5\"\n      >\n        <path\n          d=\"M0.5 0a6 6 0 0 0 6 6H12\"\n          stroke=\"currentColor\"\n          strokeDasharray={dashed ? \"2 2\" : undefined}\n        />\n      </motion.svg>\n    </motion.span>\n  );\n};\n\nexport function HookSidebar({\n  items,\n  label,\n  value,\n  defaultValue = 0,\n  onChange,\n  color = \"#FC4C01\",\n  dashed = true,\n  className,\n  ...props\n}: HookSidebarProps) {\n  const pathname = usePathname();\n  const listRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLElement | null)[]>([]);\n  const [centers, setCenters] = useState<number[]>([]);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const [pointerInside, setPointerInside] = useState(false);\n  const [focusInside, setFocusInside] = useState(false);\n\n  const routeIndex = items.findIndex((item) => hrefOf(item) === pathname);\n  const activeIndex = value ?? (routeIndex >= 0 ? routeIndex : internalValue);\n\n  useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n\n    const measure = () =>\n      setCenters(\n        itemRefs.current.map((el) =>\n          el ? el.offsetTop + el.offsetHeight / 2 : 0,\n        ),\n      );\n\n    measure();\n    const rafId = requestAnimationFrame(measure);\n    const observer = new ResizeObserver(measure);\n    observer.observe(list);\n    return () => {\n      cancelAnimationFrame(rafId);\n      observer.disconnect();\n    };\n  }, [items.length]);\n\n  const activeY = activeIndex < 0 ? null : (centers[activeIndex] ?? null);\n  const hoverY = hoverIndex === null ? null : (centers[hoverIndex] ?? null);\n\n  const hoverFrom =\n    activeY !== null && hoverY !== null && hoverY <= activeY\n      ? Math.max(0, hoverY - CORNER)\n      : (activeY ?? 0);\n\n  const select = (index: number) => {\n    if (value === undefined) setInternalValue(index);\n    onChange?.(index);\n  };\n\n  return (\n    <nav\n      data-slot=\"hook-sidebar\"\n      aria-label={label}\n      className={cn(\"flex flex-col\", className)}\n      {...props}\n    >\n      {label && (\n        <span\n          data-slot=\"hook-sidebar-label\"\n          className=\"pb-2.5 pl-0.5 pr-2 font-poppins text-[11px] font-medium uppercase tracking-wider text-zinc-500\"\n        >\n          {label}\n        </span>\n      )}\n\n      <div\n        ref={listRef}\n        onMouseLeave={() => setPointerInside(false)}\n        className=\"relative flex flex-col gap-0.5\"\n      >\n        <Rail\n          from={hoverFrom}\n          y={hoverY}\n          visible={(pointerInside || focusInside) && hoverIndex !== activeIndex}\n          dashed={dashed}\n          className=\"text-zinc-400 dark:text-zinc-600\"\n        />\n        <Rail\n          y={activeY}\n          visible={activeY !== null}\n          color={color}\n          dashed={dashed}\n        />\n\n        {items.map((item, index) => {\n          const text = labelOf(item);\n          const href = hrefOf(item);\n          const isActive = index === activeIndex;\n          const setRef = (el: HTMLElement | null) => {\n            itemRefs.current[index] = el;\n          };\n          const rowProps = {\n            \"data-slot\": \"hook-sidebar-item\",\n            \"data-active\": isActive,\n            onMouseEnter: () => {\n              setHoverIndex(index);\n              setPointerInside(true);\n            },\n            onFocus: () => {\n              setHoverIndex(index);\n              setFocusInside(true);\n            },\n            onBlur: () => setFocusInside(false),\n            onClick: (e: React.MouseEvent<HTMLElement>) => {\n              if (typeof item !== \"string\" && item.onClick) {\n                item.onClick(e);\n              }\n              if (href?.startsWith(\"#\")) {\n                e.preventDefault();\n              }\n              select(index);\n            },\n            className: cn(\n              \"rounded-lg py-1 pl-5 pr-2 text-left text-xs transition-colors duration-200 motion-reduce:transition-none select-none cursor-pointer\",\n              isActive\n                ? \"text-zinc-950 font-semibold dark:text-white dark:font-medium\"\n                : \"text-zinc-600 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-200\",\n            ),\n          };\n\n          return href ? (\n            <Link\n              key={`${index}-${text}`}\n              {...rowProps}\n              ref={setRef}\n              href={href}\n              aria-current={isActive ? \"page\" : undefined}\n            >\n              {text}\n            </Link>\n          ) : (\n            <button\n              key={`${index}-${text}`}\n              {...rowProps}\n              ref={setRef}\n              type=\"button\"\n              aria-current={isActive ? \"true\" : undefined}\n            >\n              {text}\n            </button>\n          );\n        })}\n      </div>\n    </nav>\n  );\n}\n",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ComponentProps } from \"react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst CORNER = 6;\nconst DASH =\n  \"repeating-linear-gradient(to top, transparent 0 2px, currentColor 2px 4px)\";\n\nexport type HookSidebarItem =\n  | string\n  | {\n      label: string;\n      href?: string;\n      onClick?: (e: React.MouseEvent<HTMLElement>) => void;\n    };\n\nexport type HookSidebarProps = Omit<ComponentProps<\"nav\">, \"onChange\"> & {\n  items: HookSidebarItem[];\n  label?: string;\n  value?: number;\n  defaultValue?: number;\n  onChange?: (index: number) => void;\n  color?: string;\n  dashed?: boolean;\n};\n\nconst hrefOf = (item: HookSidebarItem) =>\n  typeof item === \"string\" ? undefined : item.href;\n\nconst labelOf = (item: HookSidebarItem) =>\n  typeof item === \"string\" ? item : item.label;\n\nconst Rail = ({\n  from = 0,\n  y,\n  visible,\n  color,\n  dashed,\n  className,\n}: {\n  from?: number;\n  y: number | null;\n  visible: boolean;\n  color?: string;\n  dashed: boolean;\n  className?: string;\n}) => {\n  const reduced = useReducedMotion();\n  const travel = reduced\n    ? { duration: 0 }\n    : { type: \"spring\" as const, stiffness: 420, damping: 34, mass: 0.7 };\n\n  return (\n    <motion.span\n      aria-hidden\n      initial={false}\n      style={{ color, transition: \"color 0.25s ease\" }}\n      animate={{ opacity: visible && y !== null ? 1 : 0 }}\n      transition={reduced ? { duration: 0 } : { duration: 0.2 }}\n      className={cn(\"pointer-events-none absolute inset-0\", className)}\n    >\n      <motion.span\n        initial={false}\n        animate={{ top: from, height: Math.max(0, (y ?? 0) - CORNER - from) }}\n        transition={travel}\n        style={\n          dashed\n            ? { backgroundImage: DASH }\n            : { backgroundColor: \"currentColor\" }\n        }\n        className=\"absolute left-0.5 w-px\"\n      />\n      <motion.svg\n        initial={false}\n        animate={{ top: (y ?? 0) - CORNER }}\n        transition={travel}\n        width=\"12\"\n        height=\"7\"\n        viewBox=\"0 0 12 7\"\n        fill=\"none\"\n        className=\"absolute left-0.5\"\n      >\n        <path\n          d=\"M0.5 0a6 6 0 0 0 6 6H12\"\n          stroke=\"currentColor\"\n          strokeDasharray={dashed ? \"2 2\" : undefined}\n        />\n      </motion.svg>\n    </motion.span>\n  );\n};\n\nexport function HookSidebar({\n  items,\n  label,\n  value,\n  defaultValue = 0,\n  onChange,\n  color = \"#FC4C01\",\n  dashed = true,\n  className,\n  ...props\n}: HookSidebarProps) {\n  const pathname = usePathname();\n  const listRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLElement | null)[]>([]);\n  const [centers, setCenters] = useState<number[]>([]);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const [pointerInside, setPointerInside] = useState(false);\n  const [focusInside, setFocusInside] = useState(false);\n\n  const routeIndex = items.findIndex((item) => hrefOf(item) === pathname);\n  const activeIndex = value ?? (routeIndex >= 0 ? routeIndex : internalValue);\n\n  useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n\n    const measure = () =>\n      setCenters(\n        itemRefs.current.map((el) =>\n          el ? el.offsetTop + el.offsetHeight / 2 : 0,\n        ),\n      );\n\n    measure();\n    const rafId = requestAnimationFrame(measure);\n    const observer = new ResizeObserver(measure);\n    observer.observe(list);\n    return () => {\n      cancelAnimationFrame(rafId);\n      observer.disconnect();\n    };\n  }, [items.length]);\n\n  const activeY = activeIndex < 0 ? null : (centers[activeIndex] ?? null);\n  const hoverY = hoverIndex === null ? null : (centers[hoverIndex] ?? null);\n\n  const hoverFrom =\n    activeY !== null && hoverY !== null && hoverY <= activeY\n      ? Math.max(0, hoverY - CORNER)\n      : (activeY ?? 0);\n\n  const select = (index: number) => {\n    if (value === undefined) setInternalValue(index);\n    onChange?.(index);\n  };\n\n  return (\n    <nav\n      data-slot=\"hook-sidebar\"\n      aria-label={label}\n      className={cn(\"flex flex-col\", className)}\n      {...props}\n    >\n      {label && (\n        <span\n          data-slot=\"hook-sidebar-label\"\n          className=\"pb-2.5 pl-0.5 pr-2 font-poppins text-[11px] font-medium uppercase tracking-wider text-zinc-500\"\n        >\n          {label}\n        </span>\n      )}\n\n      <div\n        ref={listRef}\n        onMouseLeave={() => setPointerInside(false)}\n        className=\"relative flex flex-col gap-0.5\"\n      >\n        <Rail\n          from={hoverFrom}\n          y={hoverY}\n          visible={(pointerInside || focusInside) && hoverIndex !== activeIndex}\n          dashed={dashed}\n          className=\"text-zinc-400 dark:text-zinc-600\"\n        />\n        <Rail\n          y={activeY}\n          visible={activeY !== null}\n          color={color}\n          dashed={dashed}\n        />\n\n        {items.map((item, index) => {\n          const text = labelOf(item);\n          const href = hrefOf(item);\n          const isActive = index === activeIndex;\n          const setRef = (el: HTMLElement | null) => {\n            itemRefs.current[index] = el;\n          };\n          const rowProps = {\n            \"data-slot\": \"hook-sidebar-item\",\n            \"data-active\": isActive,\n            onMouseEnter: () => {\n              setHoverIndex(index);\n              setPointerInside(true);\n            },\n            onFocus: () => {\n              setHoverIndex(index);\n              setFocusInside(true);\n            },\n            onBlur: () => setFocusInside(false),\n            onClick: (e: React.MouseEvent<HTMLElement>) => {\n              if (typeof item !== \"string\" && item.onClick) {\n                item.onClick(e);\n              }\n              if (href?.startsWith(\"#\")) {\n                e.preventDefault();\n              }\n              select(index);\n            },\n            className: cn(\n              \"rounded-lg py-1 pl-5 pr-2 text-left text-xs transition-colors duration-200 motion-reduce:transition-none select-none cursor-pointer\",\n              isActive\n                ? \"text-zinc-950 font-semibold dark:text-white dark:font-medium\"\n                : \"text-zinc-600 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-200\",\n            ),\n          };\n\n          return href ? (\n            <Link\n              key={`${index}-${text}`}\n              {...rowProps}\n              ref={setRef}\n              href={href}\n              aria-current={isActive ? \"page\" : undefined}\n            >\n              {text}\n            </Link>\n          ) : (\n            <button\n              key={`${index}-${text}`}\n              {...rowProps}\n              ref={setRef}\n              type=\"button\"\n              aria-current={isActive ? \"true\" : undefined}\n            >\n              {text}\n            </button>\n          );\n        })}\n      </div>\n    </nav>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/hook-sidebar.tsx"
    }
  ],
  "type": "registry:ui",
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "title": "Hook Sidebar"
}
