{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "overlay-scroll",
  "title": "Overlay Scroll",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/overlay-scroll/overlay-scroll.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { cn } from '@/lib/utils'\n\n// Slack-style overlay scrollbar. Hides the native scrollbar entirely so the\n// scrolled content uses the full container width (no reservation), then draws\n// a thin auto-fading thumb absolutely positioned on top. Drag-to-scroll on the\n// thumb is supported via Pointer Events (mouse + touch + pen). Vertical only.\n//\n// Give the component a bounded height via the parent (e.g. `flex-1 min-h-0`\n// inside a flex column, or a fixed `h-*` / `max-h-*`). Without a bound it\n// expands to its content and the thumb is hidden.\n\n// Ported from OverlayScroll.vue's <style scoped> block. Injected once so the\n// component ships self-contained (Tailwind has no equivalent overlay-scrollbar\n// utility). Class names match the Vue source 1:1.\nconst overlayScrollCss = `\n.overlay-scroll__inner {\n  scrollbar-width: none;\n  -ms-overflow-style: none;\n  /* Stop wheel events from chaining to the page once the inner scroller\n     hits its top/bottom. Without this, scrolling a long activity feed\n     past its last item keeps scrolling the surrounding page — confusing\n     when the inner region is a clearly bounded card. */\n  overscroll-behavior: contain;\n}\n.overlay-scroll__inner::-webkit-scrollbar {\n  display: none;\n  width: 0;\n  height: 0;\n}\n.overlay-scroll__thumb {\n  position: absolute;\n  top: 0;\n  border-radius: 2px;\n  background: var(--muted-foreground);\n  opacity: 0;\n  pointer-events: none;\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  transition:\n    opacity 0.2s ease,\n    background-color 0.15s,\n    width 0.12s ease;\n  will-change: transform, opacity;\n}\n.overlay-scroll__thumb--draggable {\n  cursor: pointer;\n}\n.overlay-scroll__thumb--visible {\n  opacity: 0.4;\n  pointer-events: auto;\n}\n.overlay-scroll:hover .overlay-scroll__thumb--visible {\n  opacity: 0.6;\n}\n.overlay-scroll:hover .overlay-scroll__thumb--draggable:hover {\n  opacity: 0.8;\n  width: 8px !important;\n  background: var(--foreground);\n}\n.overlay-scroll__thumb--dragging {\n  opacity: 1 !important;\n  background: var(--foreground) !important;\n  width: 8px !important;\n}\n`\n\nfunction OverlayScrollStyle() {\n  return <style dangerouslySetInnerHTML={{ __html: overlayScrollCss }} />\n}\n\nexport interface OverlayScrollHandle {\n  /** Underlying scroller DOM element; call .scrollTo() on it from a parent. */\n  scrollerEl: HTMLElement | null\n  /** Force thumb recalc after a non-DOM size change. */\n  recompute: () => void\n}\n\nexport interface OverlayScrollProps {\n  /** Thumb width in px when idle. Expands to ~2x on hover / drag. */\n  thumbWidth?: number\n  /** Right offset of the thumb from the inner edge, in px. */\n  thumbOffset?: number\n  /** ms of scroll inactivity before the thumb fades. */\n  idleHideMs?: number\n  /** Allow dragging the thumb to scroll. */\n  draggable?: boolean\n  /** Tailwind classes forwarded to the outer wrapper. */\n  className?: string\n  children?: React.ReactNode\n}\n\nconst OverlayScroll = React.forwardRef<OverlayScrollHandle, OverlayScrollProps>(\n  ({ thumbWidth = 4, thumbOffset = 2, idleHideMs = 800, draggable = true, className, children }, ref) => {\n    const scrollerRef = React.useRef<HTMLDivElement | null>(null)\n    const thumbRef = React.useRef<HTMLDivElement | null>(null)\n\n    const [thumbHeight, setThumbHeight] = React.useState(0)\n    const [thumbTop, setThumbTop] = React.useState(0)\n    const [showThumb, setShowThumb] = React.useState(false)\n    const [isDragging, setIsDragging] = React.useState(false)\n\n    // Refs that hold the latest value for callbacks bound once (no re-bind on\n    // every render). Mirrors the Vue refs that the closures read directly.\n    const isHoveredRef = React.useRef(false)\n    const isDraggingRef = React.useRef(false)\n    const thumbHeightRef = React.useRef(0)\n    const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n    const idleHideMsRef = React.useRef(idleHideMs)\n    const draggableRef = React.useRef(draggable)\n\n    React.useEffect(() => {\n      idleHideMsRef.current = idleHideMs\n    }, [idleHideMs])\n    React.useEffect(() => {\n      draggableRef.current = draggable\n    }, [draggable])\n\n    const recompute = React.useCallback(() => {\n      const el = scrollerRef.current\n      if (!el) return\n      const ratio = el.clientHeight / el.scrollHeight\n      if (!Number.isFinite(ratio) || ratio >= 1) {\n        thumbHeightRef.current = 0\n        setThumbHeight(0)\n        return\n      }\n      const h = Math.max(24, el.clientHeight * ratio)\n      thumbHeightRef.current = h\n      setThumbHeight(h)\n      const maxScroll = el.scrollHeight - el.clientHeight\n      const maxThumb = el.clientHeight - h\n      setThumbTop(maxScroll > 0 ? (el.scrollTop / maxScroll) * maxThumb : 0)\n    }, [])\n\n    const flashThumb = React.useCallback(() => {\n      if (thumbHeightRef.current === 0) return\n      setShowThumb(true)\n      if (hideTimerRef.current) clearTimeout(hideTimerRef.current)\n      hideTimerRef.current = setTimeout(() => {\n        if (!isHoveredRef.current && !isDraggingRef.current) setShowThumb(false)\n      }, idleHideMsRef.current)\n    }, [])\n\n    const onScroll = React.useCallback(() => {\n      recompute()\n      flashThumb()\n    }, [recompute, flashThumb])\n\n    const onEnter = React.useCallback(() => {\n      isHoveredRef.current = true\n      recompute()\n      if (thumbHeightRef.current > 0) setShowThumb(true)\n    }, [recompute])\n\n    const onLeave = React.useCallback(() => {\n      isHoveredRef.current = false\n      if (isDraggingRef.current) return\n      if (hideTimerRef.current) clearTimeout(hideTimerRef.current)\n      setShowThumb(false)\n    }, [])\n\n    // Pointer Events cover mouse + touch + pen on every modern browser\n    // (Chrome 55+, Firefox 59+, Safari 13+, Edge). setPointerCapture keeps the\n    // drag alive even if the pointer leaves the thumb, matching native feel.\n    const activePointerIdRef = React.useRef<number | null>(null)\n    const dragStartYRef = React.useRef(0)\n    const dragStartScrollTopRef = React.useRef(0)\n\n    const onPointerMove = React.useCallback((e: PointerEvent) => {\n      if (activePointerIdRef.current !== e.pointerId) return\n      const el = scrollerRef.current\n      if (!el) return\n      const maxScroll = el.scrollHeight - el.clientHeight\n      const maxThumb = el.clientHeight - thumbHeightRef.current\n      if (maxThumb <= 0) return\n      const scrollRatio = maxScroll / maxThumb\n      el.scrollTop = dragStartScrollTopRef.current + (e.clientY - dragStartYRef.current) * scrollRatio\n    }, [])\n\n    const endDrag = React.useCallback(\n      (e?: PointerEvent) => {\n        if (e && activePointerIdRef.current !== e.pointerId) return\n        isDraggingRef.current = false\n        setIsDragging(false)\n        if (thumbRef.current && activePointerIdRef.current !== null) {\n          try {\n            thumbRef.current.releasePointerCapture(activePointerIdRef.current)\n          } catch {\n            // pointer may already be released; ignore\n          }\n        }\n        activePointerIdRef.current = null\n        thumbRef.current?.removeEventListener('pointermove', onPointerMove)\n        thumbRef.current?.removeEventListener('pointerup', endDrag)\n        thumbRef.current?.removeEventListener('pointercancel', endDrag)\n        if (!isHoveredRef.current) setShowThumb(false)\n      },\n      [onPointerMove],\n    )\n\n    const onThumbPointerDown = React.useCallback(\n      (e: React.PointerEvent) => {\n        if (!draggableRef.current || !scrollerRef.current || !thumbRef.current) return\n        if (e.pointerType === 'mouse' && e.button !== 0) return\n        e.preventDefault()\n        isDraggingRef.current = true\n        setIsDragging(true)\n        activePointerIdRef.current = e.pointerId\n        dragStartYRef.current = e.clientY\n        dragStartScrollTopRef.current = scrollerRef.current.scrollTop\n        thumbRef.current.setPointerCapture(e.pointerId)\n        thumbRef.current.addEventListener('pointermove', onPointerMove)\n        thumbRef.current.addEventListener('pointerup', endDrag)\n        thumbRef.current.addEventListener('pointercancel', endDrag)\n      },\n      [onPointerMove, endDrag],\n    )\n\n    React.useEffect(() => {\n      recompute()\n      const scroller = scrollerRef.current\n      if (!scroller) return\n\n      const resizeObserver = new ResizeObserver(recompute)\n      resizeObserver.observe(scroller)\n\n      const inner = scroller.firstElementChild as HTMLElement | null\n      let mutationObserver: MutationObserver | null = null\n      if (inner) {\n        resizeObserver.observe(inner)\n        mutationObserver = new MutationObserver(recompute)\n        mutationObserver.observe(inner, { childList: true, subtree: true })\n      }\n\n      return () => {\n        resizeObserver.disconnect()\n        mutationObserver?.disconnect()\n        if (hideTimerRef.current) clearTimeout(hideTimerRef.current)\n        endDrag()\n      }\n    }, [recompute, endDrag])\n\n    React.useImperativeHandle(\n      ref,\n      () => ({\n        get scrollerEl() {\n          return scrollerRef.current\n        },\n        recompute,\n      }),\n      [recompute],\n    )\n\n    return (\n      <div\n        data-uipkge=\"\"\n        data-slot=\"overlay-scroll\"\n        className={cn('overlay-scroll relative', className)}\n        onMouseEnter={onEnter}\n        onMouseLeave={onLeave}\n      >\n        <OverlayScrollStyle />\n        <div\n          ref={scrollerRef}\n          data-slot=\"overlay-scroll-viewport\"\n          className=\"overlay-scroll__inner h-full overflow-x-hidden overflow-y-auto\"\n          onScroll={onScroll}\n        >\n          {children}\n        </div>\n        <div\n          ref={thumbRef}\n          data-slot=\"overlay-scroll-thumb\"\n          className={cn(\n            'overlay-scroll__thumb',\n            showThumb && 'overlay-scroll__thumb--visible',\n            isDragging && 'overlay-scroll__thumb--dragging',\n            draggable && 'overlay-scroll__thumb--draggable',\n          )}\n          style={{\n            width: `${thumbWidth}px`,\n            /* Read the right offset from --ovs-thumb-right when an ancestor\n               sets it (e.g. Sidebar pushes the thumb away from SidebarRail),\n               otherwise fall back to the prop. Lets the same primitive sit\n               flush in a card AND clear a rail without per-call config. */\n            right: `var(--ovs-thumb-right, ${thumbOffset}px)`,\n            height: `${thumbHeight}px`,\n            transform: `translateY(${thumbTop}px)`,\n          }}\n          onPointerDown={onThumbPointerDown}\n        />\n      </div>\n    )\n  },\n)\nOverlayScroll.displayName = 'OverlayScroll'\n\nexport { OverlayScroll }\n",
      "type": "registry:ui",
      "target": "~/components/ui/overlay-scroll/overlay-scroll.tsx"
    },
    {
      "path": "packages/registry-react/components/overlay-scroll/index.ts",
      "content": "export { OverlayScroll, type OverlayScrollProps, type OverlayScrollHandle } from './overlay-scroll'\n",
      "type": "registry:ui",
      "target": "~/components/ui/overlay-scroll/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Slack-style overlay scrollbar. Hides the native scrollbar so the scrolled content uses the full container width (no gutter reservation), then draws a thin auto-fading thumb absolutely positioned on top. Drag-to-scroll via Pointer Events covers mouse, touch, and pen. Vertical only. The component does not enforce a height — give it a bounded height via the parent (`flex-1 min-h-0` in a flex column, or a fixed `h-*` / `max-h-*`).",
  "categories": [
    "layout"
  ]
}