{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-sliding-indicator",
  "type": "registry:lib",
  "title": "Sliding indicator",
  "description": "useSlidingIndicator(): one shared highlight that slides between the items of a list (sidebar rows, tabs), for hover and for the current item.",
  "files": [
    {
      "path": "src/lib/use-sliding-indicator.ts",
      "type": "registry:lib",
      "content": "import { useCallback, useLayoutEffect, useRef, type PointerEvent, type RefObject } from \"react\";\n\n/*\n * One shared highlight that slides between the items of a list (Linear and\n * Vercel style), instead of every item painting its own background.\n *\n * The container renders two absolutely positioned elements: the hover\n * indicator follows the pointer from item to item and fades out when the\n * pointer leaves the list; the active indicator sits under the current item\n * (or the item whose navigation is pending) and glides when that changes.\n * Positions are written straight to the DOM, so moving the pointer never\n * re-renders React.\n *\n * Until the first measurement the container carries `data-indicator=\"off\"`\n * and items keep their own CSS fallback (server render, no script); after it,\n * `data-indicator=\"ready\"` and items drop their own backgrounds. Style item\n * fallbacks with the `in-data-[indicator=off]:` variant.\n */\n\ntype IndicatorShape = \"fill\" | \"underline\";\n\ninterface SlidingIndicatorOptions {\n  /** CSS selector of the items, for example `[data-sidebar-row]`. */\n  item: string;\n  /** CSS selector of the current item, for example `[data-sidebar-row][data-active=true]`. */\n  active: string;\n  /** Selector of an item waiting for its navigation; it takes the active indicator first. */\n  pending?: string;\n  /** `fill` covers the item; `underline` draws a 2 px bar along its bottom edge. */\n  shape?: IndicatorShape;\n}\n\ninterface SlidingIndicator<T extends HTMLElement> {\n  containerRef: RefObject<T | null>;\n  hoverRef: RefObject<HTMLSpanElement | null>;\n  activeRef: RefObject<HTMLSpanElement | null>;\n  /** Spread on the container. */\n  handlers: {\n    onPointerOver: (event: PointerEvent<T>) => void;\n    onPointerLeave: () => void;\n  };\n}\n\n/** Attributes whose change can move the current item. */\nconst WATCHED = [\"data-active\", \"aria-current\", \"data-state\", \"data-rail\", \"data-collapsed\"];\n\nfunction place(container: HTMLElement, indicator: HTMLElement, item: Element, shape: IndicatorShape, instant: boolean) {\n  const box = container.getBoundingClientRect();\n  const rect = item.getBoundingClientRect();\n  const left = rect.left - box.left + container.scrollLeft;\n  const top = rect.top - box.top + container.scrollTop;\n  const height = shape === \"underline\" ? 2 : rect.height;\n  const y = shape === \"underline\" ? top + rect.height - height : top;\n  if (instant) indicator.style.transition = \"none\";\n  indicator.style.transform = `translate3d(${left}px, ${y}px, 0)`;\n  indicator.style.width = `${rect.width}px`;\n  indicator.style.height = `${height}px`;\n  if (instant) {\n    // Commit the jump before the transition comes back.\n    void indicator.offsetWidth;\n    indicator.style.transition = \"\";\n  }\n}\n\nfunction useSlidingIndicator<T extends HTMLElement>({\n  item,\n  active,\n  pending,\n  shape = \"fill\",\n}: SlidingIndicatorOptions): SlidingIndicator<T> {\n  const containerRef = useRef<T>(null);\n  const hoverRef = useRef<HTMLSpanElement>(null);\n  const activeRef = useRef<HTMLSpanElement>(null);\n\n  const onPointerOver = useCallback(\n    (event: PointerEvent<T>) => {\n      const container = containerRef.current;\n      const hover = hoverRef.current;\n      if (!container || !hover || event.pointerType === \"touch\") return;\n      const row = (event.target as Element).closest(item);\n      if (!row || !container.contains(row) || row.matches(\"[disabled], [aria-disabled=true]\")) return;\n      const hidden = hover.dataset.visible !== \"true\";\n      place(container, hover, row, shape, hidden);\n      hover.dataset.visible = \"true\";\n    },\n    [item, shape],\n  );\n\n  const onPointerLeave = useCallback(() => {\n    if (hoverRef.current) hoverRef.current.dataset.visible = \"false\";\n  }, []);\n\n  useLayoutEffect(() => {\n    const container = containerRef.current;\n    const indicator = activeRef.current;\n    if (!container || !indicator) return;\n\n    const findPending = (): Element | null => {\n      if (!pending) return null;\n      try {\n        return container.querySelector(pending);\n      } catch {\n        // A selector the engine cannot parse (`:has()` in an old engine): no pending state.\n        return null;\n      }\n    };\n\n    const update = () => {\n      const target = findPending() ?? container.querySelector(active);\n      if (!target || target.getClientRects().length === 0) {\n        indicator.dataset.visible = \"false\";\n      } else {\n        place(container, indicator, target, shape, indicator.dataset.visible !== \"true\");\n        indicator.dataset.visible = \"true\";\n      }\n      container.dataset.indicator = \"ready\";\n    };\n\n    update();\n    const mutations = new MutationObserver(update);\n    mutations.observe(container, { subtree: true, childList: true, attributes: true, attributeFilter: WATCHED });\n    const sizes = new ResizeObserver(update);\n    sizes.observe(container);\n    for (const child of Array.from(container.children)) sizes.observe(child);\n    return () => {\n      mutations.disconnect();\n      sizes.disconnect();\n    };\n  }, [active, pending, shape]);\n\n  return { containerRef, hoverRef, activeRef, handlers: { onPointerOver, onPointerLeave } };\n}\n\n/** Classes for an indicator element: absolutely placed at the container origin, moved by transform. */\nconst indicatorClass =\n  \"pointer-events-none absolute top-0 left-0 -z-10 opacity-0 transition-[transform,width,height,opacity] duration-200 ease-glide data-[visible=true]:opacity-100\";\n\nexport { indicatorClass, useSlidingIndicator };\nexport type { IndicatorShape, SlidingIndicator, SlidingIndicatorOptions };\n"
    }
  ]
}
