{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-nav",
  "title": "Tree Nav",
  "description": "A nav list with a tree rail and a spring marker that follows hover and settles on the active link.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@spectrumui/use-typewriter"
  ],
  "files": [
    {
      "path": "app/registry/tree-nav/tree-nav.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { animate, motion, useMotionValue, useTransform } from 'motion/react';\n\nimport { cn } from '@/lib/utils';\nimport { usePrefersReducedMotion } from '@/components/spectrumui/use-typewriter';\n\nexport interface TreeNavItem {\n  label: string;\n  href: string;\n  /** Small pill after the label, e.g. \"New\". */\n  badge?: string;\n  /** Opens in a new tab. */\n  external?: boolean;\n}\n\ntype LinkComponent =\n  'a' | React.ComponentType<React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }>;\n\nexport interface TreeNavProps {\n  items: TreeNavItem[];\n  /** href of the current page; the marker and background rest on this row. */\n  activeHref?: string;\n  /** Slide the marker and background to the hovered row and spring back on leave. */\n  followHover?: boolean;\n  /** Element used for links, e.g. Next's Link. Defaults to a plain anchor. */\n  linkComponent?: LinkComponent;\n  onSelect?: (item: TreeNavItem, event: React.MouseEvent<HTMLAnchorElement>) => void;\n  className?: string;\n}\n\nconst ROW_H = 32;\nconst MARKER = 7;\n/** Horizontal centre of the rail inside the 24px gutter. */\nconst RAIL_X = 10;\n\n// Critically damped (no overshoot) with a ~0.3s response: Apple's default for\n// repositioning UI, tightened a little for a hover highlight. Springs re-target from the live value and carry velocity,\n// so sweeping the pointer down the list never stutters or snaps.\nconst GLIDE = { type: 'spring', visualDuration: 0.22, bounce: 0 } as const;\nconst FADE = { duration: 0.12, ease: 'easeOut' } as const;\n\nexport function TreeNav({\n  items,\n  activeHref,\n  followHover = true,\n  linkComponent: Link = 'a',\n  onSelect,\n  className,\n}: TreeNavProps) {\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const rowRefs = React.useRef<(HTMLLIElement | null)[]>([]);\n  const centersRef = React.useRef<number[]>([]);\n  const hoveredRef = React.useRef<number | null>(null);\n  const reduced = usePrefersReducedMotion();\n  const activeIndex = items.findIndex((item) => item.href === activeHref);\n  // Handlers and the measurer read these through refs so they never go stale\n  // and never need to be re-created; the sync runs before the measure effect.\n  const reducedRef = React.useRef(reduced);\n  const activeRef = React.useRef(activeIndex);\n  React.useLayoutEffect(() => {\n    reducedRef.current = reduced;\n    activeRef.current = activeIndex;\n  });\n\n  // Rail length is the only measurement that renders; everything that moves is\n  // a motion value, so hovering never re-renders the list.\n  const [end, setEnd] = React.useState(0);\n  const [measured, setMeasured] = React.useState(false);\n\n  const centerY = useMotionValue(0);\n  const visibility = useMotionValue(0);\n  const pillY = useTransform(centerY, (v) => v - ROW_H / 2);\n  const markerY = useTransform(centerY, (v) => v - MARKER / 2);\n  const accentScale = useTransform(centerY, (v) => (end > 0 ? Math.min(1, v / end) : 0));\n\n  const moveTo = React.useCallback(\n    (index: number | null, immediate = false) => {\n      const centers = centersRef.current;\n      if (index === null || index < 0 || index >= centers.length) {\n        animate(visibility, 0, FADE);\n        return;\n      }\n      const target = centers[index];\n      // Coming back from hidden: appear on the row instead of travelling from\n      // wherever the marker was last parked.\n      const jump = immediate || reducedRef.current || visibility.get() < 0.05;\n      if (jump) centerY.jump(target);\n      else animate(centerY, target, GLIDE);\n      animate(visibility, 1, FADE);\n    },\n    [centerY, visibility],\n  );\n\n  React.useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const measure = () => {\n      const next = rowRefs.current\n        .slice(0, items.length)\n        .map((el) => (el ? el.offsetTop + el.offsetHeight / 2 : 0));\n      centersRef.current = next;\n      setEnd(next.length > 0 ? next[next.length - 1] : 0);\n      setMeasured(true);\n      // A re-measure is a layout change, not motion: settle instantly.\n      moveTo(hoveredRef.current ?? activeRef.current, true);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [items.length, moveTo]);\n\n  // Route changes glide; a hover in progress keeps priority.\n  React.useEffect(() => {\n    if (hoveredRef.current === null) moveTo(activeIndex);\n  }, [activeIndex, moveTo]);\n\n  const enter = (index: number) => {\n    if (!followHover) return;\n    hoveredRef.current = index;\n    moveTo(index);\n  };\n  const leave = () => {\n    if (!followHover) return;\n    hoveredRef.current = null;\n    moveTo(activeRef.current);\n  };\n\n  return (\n    <ul\n      ref={listRef}\n      className={cn('relative flex flex-col gap-0.5 ps-6', className)}\n      onPointerLeave={leave}\n    >\n      {/* Tree rail with a dot terminal, an accent run that grows to the marked\n          row, and the diamond marker. All motion is transform + opacity. */}\n      <span aria-hidden className=\"pointer-events-none absolute inset-y-0 start-0 w-5\">\n        <span\n          className=\"absolute top-0 w-px bg-neutral-200 dark:bg-neutral-800\"\n          style={{ insetInlineStart: RAIL_X - 0.5, height: end }}\n        />\n        <span\n          className=\"absolute size-1 rounded-full bg-neutral-200 dark:bg-neutral-800\"\n          style={{ insetInlineStart: RAIL_X - 2, top: end - 2 }}\n        />\n        <motion.span\n          className=\"absolute top-0 w-px origin-top bg-neutral-900 will-change-transform dark:bg-neutral-100\"\n          style={{\n            insetInlineStart: RAIL_X - 0.5,\n            height: end,\n            scaleY: accentScale,\n            opacity: visibility,\n          }}\n        />\n        <motion.span\n          className=\"absolute top-0 rounded-[1px] bg-neutral-900 will-change-transform dark:bg-neutral-100\"\n          style={{\n            insetInlineStart: RAIL_X - MARKER / 2,\n            width: MARKER,\n            height: MARKER,\n            y: markerY,\n            rotate: 45,\n            opacity: visibility,\n          }}\n        />\n      </span>\n\n      {/* One shared background: rests on the active row, follows the pointer,\n          springs back on leave. */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute end-0 start-6 top-0 rounded-lg bg-black/4 will-change-transform dark:bg-white/6\"\n        style={{ height: ROW_H, y: pillY, opacity: visibility }}\n      />\n\n      {items.map((item, index) => {\n        const isActive = index === activeIndex;\n        return (\n          <li\n            key={item.href}\n            ref={(el) => {\n              rowRefs.current[index] = el;\n            }}\n            className=\"relative\"\n            onPointerEnter={() => enter(index)}\n          >\n            <Link\n              href={item.href}\n              target={item.external ? '_blank' : undefined}\n              rel={item.external ? 'noreferrer' : undefined}\n              aria-current={isActive ? 'page' : undefined}\n              onClick={onSelect ? (event) => onSelect(item, event) : undefined}\n              onFocus={() => enter(index)}\n              onBlur={leave}\n              className={cn(\n                'flex h-8 items-center gap-2 rounded-lg px-3 text-[13px] leading-5 antialiased transition-colors duration-150 ease-out',\n                // Before the first measurement the pill has no position yet, so\n                // the active row paints its own background for that one frame.\n                isActive && !measured && 'bg-black/4 dark:bg-white/6',\n                isActive\n                  ? 'font-medium text-neutral-900 dark:text-neutral-100'\n                  : 'font-normal text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100',\n              )}\n            >\n              <span className=\"truncate\">{item.label}</span>\n              {item.badge && (\n                <span className=\"inline-flex h-[18px] shrink-0 items-center rounded-[6px] bg-[#2b7fff]/8 px-[5px] text-xs font-medium leading-none text-[#1447e6] dark:bg-[#2b7fff]/[0.14] dark:text-blue-400\">\n                  {item.badge}\n                </span>\n              )}\n            </Link>\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/tree-nav.tsx"
    }
  ],
  "type": "registry:component"
}
