{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "overlay-scroll",
  "title": "Overlay Scroll",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/overlay-scroll/OverlayScroll.vue",
      "content": "<script setup lang=\"ts\">\nimport { onMounted, onScopeDispose, ref } from 'vue'\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.\nconst props = withDefaults(\n  defineProps<{\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    class?: string\n  }>(),\n  {\n    thumbWidth: 4,\n    thumbOffset: 2,\n    idleHideMs: 800,\n    draggable: true,\n  },\n)\n\nconst scrollerEl = ref<HTMLElement | null>(null)\nconst thumbEl = ref<HTMLElement | null>(null)\nconst thumbHeight = ref(0)\nconst thumbTop = ref(0)\nconst showThumb = ref(false)\nconst isHovered = ref(false)\nconst isDragging = ref(false)\nlet hideTimer: ReturnType<typeof setTimeout> | null = null\n\nconst recompute = () => {\n  const el = scrollerEl.value\n  if (!el) return\n  const ratio = el.clientHeight / el.scrollHeight\n  if (!Number.isFinite(ratio) || ratio >= 1) {\n    thumbHeight.value = 0\n    return\n  }\n  thumbHeight.value = Math.max(24, el.clientHeight * ratio)\n  const maxScroll = el.scrollHeight - el.clientHeight\n  const maxThumb = el.clientHeight - thumbHeight.value\n  thumbTop.value = maxScroll > 0 ? (el.scrollTop / maxScroll) * maxThumb : 0\n}\n\nconst flashThumb = () => {\n  if (thumbHeight.value === 0) return\n  showThumb.value = true\n  if (hideTimer) clearTimeout(hideTimer)\n  hideTimer = setTimeout(() => {\n    if (!isHovered.value && !isDragging.value) showThumb.value = false\n  }, props.idleHideMs)\n}\n\nconst onScroll = () => {\n  recompute()\n  flashThumb()\n}\n\nconst onEnter = () => {\n  isHovered.value = true\n  recompute()\n  if (thumbHeight.value > 0) showThumb.value = true\n}\n\nconst onLeave = () => {\n  isHovered.value = false\n  if (isDragging.value) return\n  if (hideTimer) clearTimeout(hideTimer)\n  showThumb.value = 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.\nlet activePointerId: number | null = null\nlet dragStartY = 0\nlet dragStartScrollTop = 0\n\nconst onPointerMove = (e: PointerEvent) => {\n  if (activePointerId !== e.pointerId) return\n  const el = scrollerEl.value\n  if (!el) return\n  const maxScroll = el.scrollHeight - el.clientHeight\n  const maxThumb = el.clientHeight - thumbHeight.value\n  if (maxThumb <= 0) return\n  const scrollRatio = maxScroll / maxThumb\n  el.scrollTop = dragStartScrollTop + (e.clientY - dragStartY) * scrollRatio\n}\n\nconst endDrag = (e?: PointerEvent) => {\n  if (e && activePointerId !== e.pointerId) return\n  isDragging.value = false\n  if (thumbEl.value && activePointerId !== null) {\n    try {\n      thumbEl.value.releasePointerCapture(activePointerId)\n    } catch {\n      // pointer may already be released; ignore\n    }\n  }\n  activePointerId = null\n  thumbEl.value?.removeEventListener('pointermove', onPointerMove)\n  thumbEl.value?.removeEventListener('pointerup', endDrag)\n  thumbEl.value?.removeEventListener('pointercancel', endDrag)\n  if (!isHovered.value) showThumb.value = false\n}\n\nconst onThumbPointerDown = (e: PointerEvent) => {\n  if (!props.draggable || !scrollerEl.value || !thumbEl.value) return\n  if (e.pointerType === 'mouse' && e.button !== 0) return\n  e.preventDefault()\n  isDragging.value = true\n  activePointerId = e.pointerId\n  dragStartY = e.clientY\n  dragStartScrollTop = scrollerEl.value.scrollTop\n  thumbEl.value.setPointerCapture(e.pointerId)\n  thumbEl.value.addEventListener('pointermove', onPointerMove)\n  thumbEl.value.addEventListener('pointerup', endDrag)\n  thumbEl.value.addEventListener('pointercancel', endDrag)\n}\n\nlet resizeObserver: ResizeObserver | null = null\nlet mutationObserver: MutationObserver | null = null\n\nonMounted(() => {\n  recompute()\n  if (!scrollerEl.value) return\n\n  resizeObserver = new ResizeObserver(recompute)\n  resizeObserver.observe(scrollerEl.value)\n\n  const inner = scrollerEl.value.firstElementChild as HTMLElement | null\n  if (inner) {\n    resizeObserver.observe(inner)\n    mutationObserver = new MutationObserver(recompute)\n    mutationObserver.observe(inner, { childList: true, subtree: true })\n  }\n})\n\nonScopeDispose(() => {\n  resizeObserver?.disconnect()\n  mutationObserver?.disconnect()\n  if (hideTimer) clearTimeout(hideTimer)\n  endDrag()\n})\n\ndefineExpose({\n  // Underlying scroller DOM element; call .scrollTo() on it from a parent.\n  scrollerEl,\n  // Force thumb recalc after a non-DOM size change.\n  recompute,\n})\n</script>\n\n<template>\n  <div\n    data-uipkge\n    data-slot=\"overlay-scroll\"\n    :class=\"cn('overlay-scroll relative', props.class)\"\n    @mouseenter=\"onEnter\"\n    @mouseleave=\"onLeave\"\n  >\n    <div\n      ref=\"scrollerEl\"\n      data-slot=\"overlay-scroll-viewport\"\n      class=\"overlay-scroll__inner h-full overflow-x-hidden overflow-y-auto\"\n      @scroll=\"onScroll\"\n    >\n      <slot />\n    </div>\n    <div\n      ref=\"thumbEl\"\n      data-slot=\"overlay-scroll-thumb\"\n      class=\"overlay-scroll__thumb\"\n      :class=\"{\n        'overlay-scroll__thumb--visible': showThumb,\n        'overlay-scroll__thumb--dragging': isDragging,\n        'overlay-scroll__thumb--draggable': 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      @pointerdown=\"onThumbPointerDown\"\n    />\n  </div>\n</template>\n\n<style scoped>\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</style>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/overlay-scroll/OverlayScroll.vue"
    },
    {
      "path": "packages/registry-vue/components/overlay-scroll/index.ts",
      "content": "export { default as OverlayScroll } from './OverlayScroll.vue'\n",
      "type": "registry:ui",
      "target": "~/app/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"
  ]
}