{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-proximity-hover",
  "title": "useProximityHover",
  "description": "Tracks cursor distance across a list of items to preview the nearest one before it's clicked.",
  "files": [
    {
      "path": "src/hooks/use-proximity-hover.ts",
      "content": "\"use client\";\n\nimport {\n  useRef,\n  useState,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  type Dispatch,\n  type RefObject,\n  type SetStateAction,\n} from \"react\";\n\nexport interface ItemRect {\n  top: number;\n  height: number;\n  left: number;\n  width: number;\n}\n\n/**\n * Shared visual for every proximity-hover consumer's transient \"nearest\n * item\" wash (Accordion, Tabs, DocsSidebar, DocsMobileSidebar, the Drawer\n * examples' nav/settings lists — anywhere `useProximityHover` drives a\n * pill). A faint --foreground tint reused as-is everywhere.\n */\nexport const proximityHoverWashClassName = \"bg-hover\";\n\n/**\n * Cap on that wash's peak layer-opacity while animating in. At 1 (full\n * layer-opacity) the already-translucent wash lands close enough to a\n * persistent selected/expanded state built from a similarly light neutral\n * token (bg-card, bg-muted, bg-accent/20) to read as the same color,\n * especially in dark mode. 0.4 keeps it a clearly subordinate preview.\n */\nexport const proximityHoverWashOpacity = 0.4;\n\ninterface UseProximityHoverOptions {\n  /**\n   * Which direction to resolve the nearest item along.\n   *   \"y\"  — vertical lists (default): closest by top/height\n   *   \"x\"  — horizontal strips: closest by left/width\n   *   \"xy\" — 2-D grids: closest card across both rows AND columns,\n   *          measured by Euclidean distance to each item's center\n   */\n  axis?: \"x\" | \"y\" | \"xy\";\n}\n\ninterface UseProximityHoverReturn {\n  activeIndex: number | null;\n  setActiveIndex: Dispatch<SetStateAction<number | null>>;\n  itemRects: ItemRect[];\n  /**\n   * True once every registered item has been measured and no remeasure is\n   * pending, i.e. `itemRects` describes the current item set. Gate absolutely\n   * positioned overlays on it: an overlay that mounts against a rect a later\n   * pass still corrects animates from the wrong place to the right one, which\n   * reads as the highlight sliding in from another row.\n   */\n  isMeasured: boolean;\n  sessionRef: RefObject<number>;\n  handlers: {\n    onMouseMove: (e: React.MouseEvent) => void;\n    onMouseEnter: () => void;\n    onMouseLeave: () => void;\n  };\n  registerItem: (index: number, element: HTMLElement | null) => void;\n  /**\n   * Invalidates the published rects and runs the hook's coalesced measurement\n   * pass again, holding `isMeasured` false until it settles. Reach for it when\n   * something other than item registration invalidates layout — a popup that\n   * stays mounted between opens keeps its items registered, so nothing else\n   * would notice that its rects were taken while it was hidden.\n   */\n  remeasure: () => void;\n  measureItems: () => void;\n}\n\n/**\n * How many frames the coalesced remeasure retries while the registered items\n * still have no layout box. A popup can be in the DOM one frame before it is\n * laid out; retrying beats publishing zeroed rects, and the cap keeps a list\n * that stays hidden for good from spinning frames forever.\n */\nconst measurementAttempts = 3;\n\n/**\n * Drives \"proximity hover\": in an interactive list/grid, highlight the item\n * nearest the cursor before the user clicks, rather than only lighting up\n * on direct :hover. Consumers register their item elements by index and get\n * back the nearest index plus its rect, to position a moving highlight\n * behind the list.\n */\nexport function useProximityHover<T extends HTMLElement>(\n  containerRef: RefObject<T | null>,\n  options: UseProximityHoverOptions = {},\n): UseProximityHoverReturn {\n  const { axis = \"y\" } = options;\n  const itemsRef = useRef(new Map<number, HTMLElement>());\n  const [activeIndex, setActiveIndex] = useState<number | null>(null);\n  const [itemRects, setItemRects] = useState<ItemRect[]>([]);\n  const [isMeasured, setIsMeasured] = useState(false);\n  const [registerTick, setRegisterTick] = useState(0);\n  const itemRectsRef = useRef<ItemRect[]>([]);\n  const sessionRef = useRef(0);\n  const rafIdRef = useRef<number | null>(null);\n  const remeasureRafIdRef = useRef<number | null>(null);\n\n  /**\n   * Publishes a rect for every registered item. Returns false when the\n   * measurement could not be completed (no container, or an item without a\n   * layout box) — nothing is published in that case, so the last complete\n   * measurement stands instead of being overwritten with zeroes.\n   */\n  const runMeasurement = useCallback(() => {\n    const container = containerRef.current;\n    if (!container) return false;\n    const rects: ItemRect[] = [];\n    let everyItemHasLayout = true;\n    itemsRef.current.forEach((element, index) => {\n      // An element inside a display:none / not-yet-laid-out popup has no\n      // offsetParent and reports every offset as 0. Publishing that would pin\n      // overlays to the top of the list, so treat the whole pass as\n      // incomplete. A boxless element is the only case: `position: fixed`\n      // items also have no offsetParent but do have a size.\n      const hasLayoutBox =\n        element.offsetParent !== null || element.offsetWidth > 0 || element.offsetHeight > 0;\n      if (!hasLayoutBox) {\n        everyItemHasLayout = false;\n        return;\n      }\n      // Use offset* instead of getBoundingClientRect so measurements are\n      // unaffected by CSS transforms (e.g. scaleY animation on the parent\n      // motion.div). offsetTop/offsetLeft are layout values relative to the\n      // offsetParent (the scroll container), matching the coordinate space\n      // used by `position: absolute` children.\n      rects[index] = {\n        top: element.offsetTop,\n        height: element.offsetHeight,\n        left: element.offsetLeft,\n        width: element.offsetWidth,\n      };\n    });\n    if (!everyItemHasLayout) return false;\n    // Skip the state update when nothing moved (a cheap top/left/width/height\n    // compare) so redundant remeasures don't churn re-renders.\n    const prev = itemRectsRef.current;\n    let changed = prev.length !== rects.length;\n    for (let i = 0; !changed && i < rects.length; i++) {\n      const p = prev[i];\n      const r = rects[i];\n      if (p === r) continue; // both undefined (sparse slot)\n      changed =\n        !p ||\n        !r ||\n        p.top !== r.top ||\n        p.left !== r.left ||\n        p.width !== r.width ||\n        p.height !== r.height;\n    }\n    if (changed) {\n      itemRectsRef.current = rects;\n      setItemRects(rects);\n    }\n    return true;\n  }, [containerRef]);\n\n  const measureItems = useCallback(() => {\n    runMeasurement();\n  }, [runMeasurement]);\n\n  /**\n   * The hook's single measurement pass: coalesces every trigger (item\n   * registration, container resize) into one remeasure on the next frame and\n   * is the only place readiness is reported, so `isMeasured` can never turn\n   * true while another pass is still queued.\n   */\n  const scheduleMeasurement = useCallback(\n    (attemptsLeft: number) => {\n      if (remeasureRafIdRef.current !== null) {\n        cancelAnimationFrame(remeasureRafIdRef.current);\n      }\n      remeasureRafIdRef.current = requestAnimationFrame(() => {\n        remeasureRafIdRef.current = null;\n        if (runMeasurement()) {\n          setIsMeasured(true);\n        } else if (attemptsLeft > 1) {\n          scheduleMeasurement(attemptsLeft - 1);\n        }\n      });\n    },\n    [runMeasurement],\n  );\n\n  const remeasure = useCallback(() => {\n    // Readiness drops first: until the pass below settles, the published rects\n    // may not describe what is on screen, and an overlay positioned from them\n    // would be corrected after mounting — which animates as a slide.\n    setIsMeasured(false);\n    scheduleMeasurement(measurementAttempts);\n  }, [scheduleMeasurement]);\n\n  const registerItem = useCallback((index: number, element: HTMLElement | null) => {\n    if (element) {\n      itemsRef.current.set(index, element);\n    } else {\n      itemsRef.current.delete(index);\n    }\n    // Bump a tick rather than calling remeasure() directly. Consumers that\n    // register items from a `useLayoutEffect` (e.g. Tabs) all fire within the\n    // same pre-paint commit; React batches the resulting setState calls, so\n    // the effect below runs once, after every sibling has registered, still\n    // before the browser paints — no bare-then-populated flash on mount, and\n    // no per-item measurement pass reading a still-partial item set.\n    setRegisterTick((t) => t + 1);\n  }, []);\n\n  // Coalesced pass for registration changes specifically (see registerItem\n  // above). Falls back to the rAF retry loop only when an item exists but\n  // hasn't been laid out yet (e.g. it's inside a not-yet-visible popup) —\n  // the same case `runMeasurement`/`scheduleMeasurement` already handle.\n  useLayoutEffect(() => {\n    if (registerTick === 0) return;\n    if (runMeasurement()) {\n      setIsMeasured(true);\n    } else {\n      setIsMeasured(false);\n      scheduleMeasurement(measurementAttempts);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [registerTick]);\n\n  const handleMouseMove = useCallback(\n    (e: React.MouseEvent) => {\n      // React bubbles synthetic events along the component tree, not the DOM\n      // tree, so a portaled descendant (e.g. NavigationMenuContent, teleported\n      // into a shared Viewport elsewhere in the DOM) still reaches this\n      // handler. Guard on real DOM containment so hovering that portaled\n      // content can't drag the pill across unrelated trigger rects.\n      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {\n        return;\n      }\n\n      const mouseX = e.clientX;\n      const mouseY = e.clientY;\n\n      if (rafIdRef.current !== null) {\n        cancelAnimationFrame(rafIdRef.current);\n      }\n\n      rafIdRef.current = requestAnimationFrame(() => {\n        rafIdRef.current = null;\n        const container = containerRef.current;\n        if (!container) return;\n\n        const containerRect = container.getBoundingClientRect();\n\n        // ── 2-D grid path ──────────────────────────────────────────\n        // When items wrap into rows and columns, a single-axis nearest pick\n        // can't tell which card the cursor is closest to. Resolve by\n        // Euclidean distance to each item's center, and prefer any item the\n        // cursor is actually inside (point-in-rect).\n        if (axis === \"xy\") {\n          let closestIndex: number | null = null;\n          let closestDistance = Infinity;\n          let containingIndex: number | null = null;\n\n          const rects = itemRectsRef.current;\n          const scrollX = container.scrollLeft;\n          const scrollY = container.scrollTop;\n          const borderX = container.clientLeft;\n          const borderY = container.clientTop;\n          // Map layout coords into visual/viewport space, accounting for any\n          // cumulative ancestor transform: scale (see the single-axis note\n          // below). X and Y scale independently.\n          const scaleX =\n            container.offsetWidth > 0 ? containerRect.width / container.offsetWidth : 1;\n          const scaleY =\n            container.offsetHeight > 0 ? containerRect.height / container.offsetHeight : 1;\n\n          for (let index = 0; index < rects.length; index++) {\n            const r = rects[index];\n            if (!r) continue;\n\n            const left = containerRect.left + (borderX + r.left - scrollX) * scaleX;\n            const top = containerRect.top + (borderY + r.top - scrollY) * scaleY;\n            const width = r.width * scaleX;\n            const height = r.height * scaleY;\n\n            if (\n              mouseX >= left &&\n              mouseX <= left + width &&\n              mouseY >= top &&\n              mouseY <= top + height\n            ) {\n              containingIndex = index;\n            }\n\n            const dx = mouseX - (left + width / 2);\n            const dy = mouseY - (top + height / 2);\n            const distance = Math.hypot(dx, dy);\n\n            if (distance < closestDistance) {\n              closestDistance = distance;\n              closestIndex = index;\n            }\n          }\n\n          setActiveIndex(containingIndex ?? closestIndex);\n          return;\n        }\n\n        const mousePos = axis === \"x\" ? mouseX : mouseY;\n\n        let closestIndex: number | null = null;\n        let closestDistance = Infinity;\n        let containingIndex: number | null = null;\n\n        const rects = itemRectsRef.current;\n        // Convert content-relative rects to viewport coords using live scroll.\n        const scrollOffset = axis === \"x\" ? container.scrollLeft : container.scrollTop;\n        const borderOffset = axis === \"x\" ? container.clientLeft : container.clientTop;\n        const containerEdge = axis === \"x\" ? containerRect.left : containerRect.top;\n        // Item rects are layout values (offset*); the container's bounding\n        // rect reflects any cumulative ancestor transform: scale. Compute the\n        // scale factor so we can map layout coords into the same visual\n        // viewport space the mouse cursor lives in.\n        const layoutSize = axis === \"x\" ? container.offsetWidth : container.offsetHeight;\n        const visualSize = axis === \"x\" ? containerRect.width : containerRect.height;\n        const scale = layoutSize > 0 ? visualSize / layoutSize : 1;\n\n        for (let index = 0; index < rects.length; index++) {\n          const r = rects[index];\n          if (!r) continue;\n\n          const contentPos = axis === \"x\" ? r.left : r.top;\n          const itemStart = containerEdge + (borderOffset + contentPos - scrollOffset) * scale;\n          const itemSize = (axis === \"x\" ? r.width : r.height) * scale;\n          const itemEnd = itemStart + itemSize;\n\n          if (mousePos >= itemStart && mousePos <= itemEnd) {\n            containingIndex = index;\n          }\n\n          const itemCenter = itemStart + itemSize / 2;\n          const distance = Math.abs(mousePos - itemCenter);\n\n          if (distance < closestDistance) {\n            closestDistance = distance;\n            closestIndex = index;\n          }\n        }\n\n        setActiveIndex(containingIndex ?? closestIndex);\n      });\n    },\n    [axis, containerRef],\n  );\n\n  const handleMouseEnter = useCallback(() => {\n    sessionRef.current += 1;\n  }, []);\n\n  const handleMouseLeave = useCallback(() => {\n    if (rafIdRef.current !== null) {\n      cancelAnimationFrame(rafIdRef.current);\n      rafIdRef.current = null;\n    }\n    setActiveIndex(null);\n  }, []);\n\n  // Remeasure when the container resizes — a reflow moves items even though\n  // the registered set is unchanged, which would otherwise leave itemRects\n  // stale. Coalesced through the same rAF as register/unregister. Readiness\n  // is deliberately not dropped: the item set is unchanged, so the published\n  // rects stay usable, and hiding overlays on every reflow would flicker them.\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(() => scheduleMeasurement(measurementAttempts));\n    ro.observe(container);\n    return () => ro.disconnect();\n  }, [containerRef, scheduleMeasurement]);\n\n  useEffect(() => {\n    return () => {\n      if (rafIdRef.current !== null) {\n        cancelAnimationFrame(rafIdRef.current);\n      }\n      if (remeasureRafIdRef.current !== null) {\n        cancelAnimationFrame(remeasureRafIdRef.current);\n      }\n    };\n  }, []);\n\n  return {\n    activeIndex,\n    setActiveIndex,\n    itemRects,\n    isMeasured,\n    sessionRef,\n    handlers: {\n      onMouseMove: handleMouseMove,\n      onMouseEnter: handleMouseEnter,\n      onMouseLeave: handleMouseLeave,\n    },\n    registerItem,\n    remeasure,\n    measureItems,\n  };\n}\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}