{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-proximity-hover", "type": "registry:hook", "title": "useProximityHover", "description": "React hook for proximity-based hover detection. Tracks the closest item to the mouse cursor within a container, enabling smooth animated background indicators.", "files": [ { "path": "packages/ui/src/hooks/use-proximity-hover.ts", "content": "\"use client\";\nimport { useRef, useState, useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction, } from \"react\";\nexport interface ItemRect {\n top: number;\n height: number;\n left: number;\n width: number;\n radius: number;\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}\ninterface UseProximityHoverReturn {\n activeIndex: number | null;\n setActiveIndex: Dispatch>;\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;\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 * 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;\nfunction readRadius(element: HTMLElement): number {\n const value = getComputedStyle(element).borderTopLeftRadius.trim();\n const match = /^(\\d*\\.?\\d+)px$/.exec(value);\n return match ? Number(match[1]) : 0;\n}\nexport function useProximityHover(containerRef: RefObject, options: UseProximityHoverOptions = {}): UseProximityHoverReturn {\n const { axis = \"y\" } = options;\n const itemsRef = useRef(new Map());\n const [activeIndex, setActiveIndex] = useState(null);\n const [itemRects, setItemRects] = useState([]);\n const [isMeasured, setIsMeasured] = useState(false);\n const itemRectsRef = useRef([]);\n const sessionRef = useRef(0);\n const rafIdRef = useRef(null);\n const remeasureRafIdRef = useRef(null);\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)\n 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 = element.offsetParent !== null ||\n element.offsetWidth > 0 ||\n 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 radius: readRadius(element),\n };\n });\n if (!everyItemHasLayout)\n 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)\n 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 p.radius !== r.radius;\n }\n if (changed) {\n itemRectsRef.current = rects;\n setItemRects(rects);\n }\n return true;\n }, [containerRef]);\n const measureItems = useCallback(() => {\n runMeasurement();\n }, [runMeasurement]);\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((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 }\n else if (attemptsLeft > 1) {\n scheduleMeasurement(attemptsLeft - 1);\n }\n });\n }, [runMeasurement]);\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 const registerItem = useCallback((index: number, element: HTMLElement | null) => {\n if (element) {\n itemsRef.current.set(index, element);\n }\n else {\n itemsRef.current.delete(index);\n }\n // Coalesce rapid register/unregister calls (e.g. when an AnimatePresence\n // remounts a list of rows) into a single remeasure on the next frame,\n // so consumers don't have to manually call measureItems after the\n // container's children swap.\n remeasure();\n }, [remeasure]);\n const handleMouseMove = useCallback((e: React.MouseEvent) => {\n const mouseX = e.clientX;\n const mouseY = e.clientY;\n if (rafIdRef.current !== null) {\n cancelAnimationFrame(rafIdRef.current);\n }\n rafIdRef.current = requestAnimationFrame(() => {\n rafIdRef.current = null;\n const container = containerRef.current;\n if (!container)\n return;\n const containerRect = container.getBoundingClientRect();\n // ── 2-D grid path ──────────────────────────────────────────\n // When items wrap into rows and columns, a single-axis nearest\n // pick can't tell which card the cursor is closest to. Resolve\n // by Euclidean distance to each item's center, and prefer any\n // item the 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 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 = container.offsetWidth > 0\n ? containerRect.width / container.offsetWidth\n : 1;\n const scaleY = container.offsetHeight > 0\n ? containerRect.height / container.offsetHeight\n : 1;\n for (let index = 0; index < rects.length; index++) {\n const r = rects[index];\n if (!r)\n continue;\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 if (mouseX >= left &&\n mouseX <= left + width &&\n mouseY >= top &&\n mouseY <= top + height) {\n containingIndex = index;\n }\n const dx = mouseX - (left + width / 2);\n const dy = mouseY - (top + height / 2);\n const distance = Math.hypot(dx, dy);\n if (distance < closestDistance) {\n closestDistance = distance;\n closestIndex = index;\n }\n }\n setActiveIndex(containingIndex ?? closestIndex);\n return;\n }\n const mousePos = axis === \"x\" ? mouseX : mouseY;\n let closestIndex: number | null = null;\n let closestDistance = Infinity;\n let containingIndex: number | null = null;\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 rect\n // reflects any cumulative ancestor transform: scale. Compute the scale\n // factor so we can map layout coords into the same visual viewport\n // 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 for (let index = 0; index < rects.length; index++) {\n const r = rects[index];\n if (!r)\n continue;\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 if (mousePos >= itemStart && mousePos <= itemEnd) {\n containingIndex = index;\n }\n const itemCenter = itemStart + itemSize / 2;\n const distance = Math.abs(mousePos - itemCenter);\n if (distance < closestDistance) {\n closestDistance = distance;\n closestIndex = index;\n }\n }\n setActiveIndex(containingIndex ?? closestIndex);\n });\n }, [axis, containerRef]);\n const handleMouseEnter = useCallback(() => {\n sessionRef.current += 1;\n }, []);\n const handleMouseLeave = useCallback(() => {\n if (rafIdRef.current !== null) {\n cancelAnimationFrame(rafIdRef.current);\n rafIdRef.current = null;\n }\n setActiveIndex(null);\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 is\n // deliberately not dropped: the item set is unchanged, so the published rects\n // stay usable, and hiding overlays on every reflow would flicker them.\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof ResizeObserver === \"undefined\")\n return;\n const ro = new ResizeObserver(() => scheduleMeasurement(measurementAttempts));\n ro.observe(container);\n return () => ro.disconnect();\n }, [containerRef, scheduleMeasurement]);\n // Radius changes do not change box dimensions, so ResizeObserver cannot\n // notice a host application's runtime Tailwind theme update. Dispatch this\n // event after updating --radius-* to invalidate the geometry measurement.\n useEffect(() => {\n const handleRadiusChange = () => remeasure();\n window.addEventListener(\"zeron:radius-change\", handleRadiusChange);\n return () => window.removeEventListener(\"zeron:radius-change\", handleRadiusChange);\n }, [remeasure]);\n // Clean up rAF on unmount\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 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/**\n * Hook for child items to register themselves with the proximity hover system.\n * Call in useEffect with the item's ref and index.\n */\nexport function useRegisterProximityItem(registerItem: (index: number, element: HTMLElement | null) => void, index: number, ref: RefObject) {\n useEffect(() => {\n registerItem(index, ref.current);\n return () => registerItem(index, null);\n }, [index, registerItem, ref]);\n}\n", "type": "registry:hook", "target": "hooks/use-proximity-hover.ts" } ] }