{ "$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 | 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(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(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) => {\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) => {\n if (!dragging) return\n scrollToPointer(e.clientY, grabRef.current)\n }\n\n const endDrag = (e: React.PointerEvent) => {\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 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
\n \n
\n \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" } }