{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-indicator",
  "type": "registry:ui",
  "title": "ScrollIndicator",
  "description": "Read-only auto-hiding scroll thumb for scrollers whose native scrollbar would run behind floating chrome.",
  "categories": [
    "display"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/use-scroll-indicator.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/scroll-indicator.tsx",
      "type": "registry:ui",
      "target": "components/ui/scroll-indicator.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { useScrollIndicator } from '@/hooks/use-scroll-indicator'\n\nexport interface ScrollIndicatorProps {\n  /**\n   * The scroll container to track, or `null` to track the DOCUMENT scroller.\n   *\n   * In document mode the indicator pins to the viewport (`fixed`) rather than to\n   * a `relative` parent, since the thing it describes is the page itself.\n   */\n  scrollRef: React.RefObject<HTMLElement> | null\n  /**\n   * Pixels from the top of the positioned parent where the track starts.\n   * Set it to the height of whatever floats over the scroller's top edge\n   * (a frosted header) plus a small gap, so the thumb never runs behind it.\n   */\n  topOffset?: number\n  /** Pixels from the bottom of the positioned parent. */\n  bottomOffset?: number\n  /**\n   * Hide the indicator below the `md` breakpoint (default true) -- page-level\n   * scrollers keep the platform's overlay scrollbars on mobile. Pass false\n   * for surfaces that hide their native scrollbar at every size (drawers).\n   */\n  hideBelowMd?: boolean\n  /**\n   * Let a pointer grab the thumb and scroll with it, thickening on hover.\n   *\n   * Off by default, and deliberately opt-in rather than the standard behaviour:\n   * an interactive track has to accept pointer events, and a strip down the\n   * inline edge of a small surface -- a drawer, a sidebar rail -- would sit on top\n   * of content a click needs to reach. Turn it on for a page-sized scroller,\n   * where the edge is empty and a native scrollbar is what a mouse expects.\n   *\n   * It stays `aria-hidden` and unfocusable even when interactive, which is\n   * deliberate. It is a redundant affordance: the content it scrolls is already\n   * reachable by wheel, touch, and keyboard, and a native overlay scrollbar is\n   * likewise not exposed as a widget. Adding `role=\"scrollbar\"` would promise a\n   * keyboard model that the page's own scrolling already provides.\n   */\n  interactive?: boolean\n  className?: string\n}\n\n/**\n * Custom auto-hiding scroll indicator. Renders inside a `relative` parent\n * that wraps (or contains) the scroll container, replacing a native\n * scrollbar that would otherwise run behind floating/sticky chrome. Pair it\n * with `.scrollbar-hide` on the scroll container. Hidden from assistive tech,\n * and inert unless `interactive`.\n */\nexport const ScrollIndicator = React.forwardRef<HTMLDivElement, ScrollIndicatorProps>(function ScrollIndicator({\n  scrollRef,\n  topOffset = 8,\n  bottomOffset = 8,\n  hideBelowMd = true,\n  interactive = false,\n  className,\n}, ref) {\n  const { thumbHeightPct, thumbTopPct, visible, hasOverflow } = useScrollIndicator(scrollRef)\n  const trackRef = React.useRef<HTMLDivElement>(null)\n  const [hovered, setHovered] = React.useState(false)\n  const [dragging, setDragging] = React.useState(false)\n  const grabRef = React.useRef(0)\n\n  const documentMode = scrollRef === null\n  const scroller = () => (documentMode ? document.documentElement : scrollRef?.current ?? null)\n\n  /**\n   * Map a pointer position on the track to a scroll offset.\n   *\n   * The hook expresses the thumb as percentages of the TRACK, and its top% plus\n   * its height% always sum to 100 -- so the thumb's travel is exactly\n   * `trackHeight - thumbHeight`, and the mapping back is linear.\n   */\n  const scrollToPointer = React.useCallback(\n    (clientY: number, grab: number) => {\n      const el = scroller()\n      const track = trackRef.current\n      if (!el || !track) return\n      const rect = track.getBoundingClientRect()\n      const thumbH = Math.max((thumbHeightPct / 100) * rect.height, 24)\n      const travel = rect.height - thumbH\n      if (travel <= 0) return\n      const offset = Math.min(Math.max(clientY - rect.top - grab, 0), travel)\n      el.scrollTop = (offset / travel) * (el.scrollHeight - el.clientHeight)\n    },\n    // `scroller` reads a ref at call time, so only the thumb size matters here.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [thumbHeightPct, documentMode],\n  )\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!interactive || e.button !== 0) return\n    const track = trackRef.current\n    if (!track) return\n    const rect = track.getBoundingClientRect()\n    const thumbTop = rect.top + (thumbTopPct / 100) * rect.height\n    const thumbH = Math.max((thumbHeightPct / 100) * rect.height, 24)\n    const onThumb = e.clientY >= thumbTop && e.clientY <= thumbTop + thumbH\n    // Grabbing the thumb keeps the point under the cursor; clicking bare track\n    // centres it there, which is what a native scrollbar does on a jump.\n    grabRef.current = onThumb ? e.clientY - thumbTop : thumbH / 2\n    setDragging(true)\n    e.currentTarget.setPointerCapture(e.pointerId)\n    scrollToPointer(e.clientY, grabRef.current)\n    e.preventDefault()\n  }\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!dragging) return\n    scrollToPointer(e.clientY, grabRef.current)\n  }\n\n  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!dragging) return\n    setDragging(false)\n    e.currentTarget.releasePointerCapture(e.pointerId)\n  }\n\n  if (!hasOverflow) return null\n\n  // Held open while the pointer is on it or dragging, so it does not fade out\n  // from under the cursor mid-drag.\n  const shown = visible || (interactive && (hovered || dragging))\n  const thick = interactive && (hovered || dragging)\n\n  return (\n    <div\n      ref={ref}\n      aria-hidden=\"true\"\n      className={cn(\n        // Logical inset: inline-end puts the track on the left in RTL locales,\n        // matching where native scrollbars sit there.\n        documentMode ? 'fixed end-1 z-40' : 'absolute end-1 z-40',\n        interactive ? 'cursor-default' : 'pointer-events-none',\n        hideBelowMd && 'hidden md:block',\n        className,\n      )}\n      // A 6px strip is hard to hit; an interactive one gets a wider invisible\n      // hit area while the visible thumb stays thin.\n      style={{ top: topOffset, bottom: bottomOffset, width: interactive ? 14 : 6 }}\n      onPointerEnter={interactive ? () => setHovered(true) : undefined}\n      onPointerLeave={interactive ? () => setHovered(false) : undefined}\n      onPointerDown={interactive ? onPointerDown : undefined}\n      onPointerMove={interactive ? onPointerMove : undefined}\n      onPointerUp={interactive ? endDrag : undefined}\n      onPointerCancel={interactive ? endDrag : undefined}\n    >\n      <div ref={trackRef} className=\"relative h-full\">\n        <div\n          className={cn(\n            'absolute end-0 rounded-full bg-[var(--color-neutral-400)] dark:bg-[var(--color-neutral-500)]',\n            'transition-[opacity,width] duration-200',\n            thick ? 'w-2' : 'w-1',\n          )}\n          style={{\n            height: `${thumbHeightPct}%`,\n            top: `${thumbTopPct}%`,\n            minHeight: 24,\n            opacity: shown ? (thick ? 0.85 : 0.6) : 0,\n          }}\n        />\n      </div>\n    </div>\n  )\n})\n\nScrollIndicator.displayName = 'ScrollIndicator'\n"
    }
  ],
  "docs": "Render it inside a `relative` parent that contains the scroll container, and pair it with .scrollbar-hide on the scroller. Set topOffset to the floating header's height plus a few pixels (68 for a 64px header). Purely visual: aria-hidden and pointer-events-none. BottomDrawer embeds one already.",
  "meta": {
    "group": "display",
    "related": [
      "use-scroll-indicator",
      "app-shell",
      "bottom-drawer"
    ],
    "exports": [
      "ScrollIndicator"
    ],
    "siteSlug": "scroll-indicator"
  }
}
