{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "overlay-scroll",
  "title": "Overlay Scroll",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/overlay-scroll/OverlayScroll.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export interface OverlayScrollProps extends HTMLAttributes<HTMLDivElement> {\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    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { 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  let {\n    class: className,\n    thumbWidth = 4,\n    thumbOffset = 2,\n    idleHideMs = 800,\n    draggable = true,\n    children,\n    ref = $bindable(null),\n    onmouseenter,\n    onmouseleave,\n    ...restProps\n  }: OverlayScrollProps = $props()\n\n  let scrollerEl: HTMLElement | null = $state(null)\n  let thumbEl: HTMLElement | null = $state(null)\n  let thumbHeight = $state(0)\n  let thumbTop = $state(0)\n  let showThumb = $state(false)\n  let isHovered = $state(false)\n  let isDragging = $state(false)\n  let hideTimer: ReturnType<typeof setTimeout> | null = null\n\n  function recompute() {\n    const el = scrollerEl\n    if (!el) return\n    const ratio = el.clientHeight / el.scrollHeight\n    if (!Number.isFinite(ratio) || ratio >= 1) {\n      thumbHeight = 0\n      return\n    }\n    thumbHeight = Math.max(24, el.clientHeight * ratio)\n    const maxScroll = el.scrollHeight - el.clientHeight\n    const maxThumb = el.clientHeight - thumbHeight\n    thumbTop = maxScroll > 0 ? (el.scrollTop / maxScroll) * maxThumb : 0\n  }\n\n  function flashThumb() {\n    if (thumbHeight === 0) return\n    showThumb = true\n    if (hideTimer) clearTimeout(hideTimer)\n    hideTimer = setTimeout(() => {\n      if (!isHovered && !isDragging) showThumb = false\n    }, idleHideMs)\n  }\n\n  function onScroll() {\n    recompute()\n    flashThumb()\n  }\n\n  function onEnter(e: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }) {\n    isHovered = true\n    recompute()\n    if (thumbHeight > 0) showThumb = true\n    onmouseenter?.(e)\n  }\n\n  function onLeave(e: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }) {\n    isHovered = false\n    if (isDragging) {\n      onmouseleave?.(e)\n      return\n    }\n    if (hideTimer) clearTimeout(hideTimer)\n    showThumb = false\n    onmouseleave?.(e)\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  let activePointerId: number | null = null\n  let dragStartY = 0\n  let dragStartScrollTop = 0\n\n  function onPointerMove(e: PointerEvent) {\n    if (activePointerId !== e.pointerId) return\n    const el = scrollerEl\n    if (!el) return\n    const maxScroll = el.scrollHeight - el.clientHeight\n    const maxThumb = el.clientHeight - thumbHeight\n    if (maxThumb <= 0) return\n    const scrollRatio = maxScroll / maxThumb\n    el.scrollTop = dragStartScrollTop + (e.clientY - dragStartY) * scrollRatio\n  }\n\n  function endDrag(e?: PointerEvent) {\n    if (e && activePointerId !== e.pointerId) return\n    isDragging = false\n    if (thumbEl && activePointerId !== null) {\n      try {\n        thumbEl.releasePointerCapture(activePointerId)\n      } catch {\n        // pointer may already be released; ignore\n      }\n    }\n    activePointerId = null\n    thumbEl?.removeEventListener('pointermove', onPointerMove)\n    thumbEl?.removeEventListener('pointerup', endDrag as EventListener)\n    thumbEl?.removeEventListener('pointercancel', endDrag as EventListener)\n    if (!isHovered) showThumb = false\n  }\n\n  function onThumbPointerDown(e: PointerEvent & { currentTarget: EventTarget & HTMLDivElement }) {\n    if (!draggable || !scrollerEl || !thumbEl) return\n    if (e.pointerType === 'mouse' && e.button !== 0) return\n    e.preventDefault()\n    isDragging = true\n    activePointerId = e.pointerId\n    dragStartY = e.clientY\n    dragStartScrollTop = scrollerEl.scrollTop\n    thumbEl.setPointerCapture(e.pointerId)\n    thumbEl.addEventListener('pointermove', onPointerMove)\n    thumbEl.addEventListener('pointerup', endDrag as EventListener)\n    thumbEl.addEventListener('pointercancel', endDrag as EventListener)\n  }\n\n  $effect(() => {\n    recompute()\n    if (!scrollerEl) return\n\n    const resizeObserver = new ResizeObserver(recompute)\n    resizeObserver.observe(scrollerEl)\n\n    const inner = scrollerEl.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 (hideTimer) clearTimeout(hideTimer)\n      endDrag()\n    }\n  })\n\n  /** Underlying scroller DOM element; call .scrollTo() on it from a parent. */\n  export function getScroller(): HTMLElement | null {\n    return scrollerEl\n  }\n\n  /** Force thumb recalc after a non-DOM size change. */\n  export { recompute }\n</script>\n\n<div\n  bind:this={ref}\n  data-uipkge=\"\"\n  data-slot=\"overlay-scroll\"\n  class={cn('overlay-scroll relative', className)}\n  onmouseenter={onEnter}\n  onmouseleave={onLeave}\n  {...restProps}\n>\n  <div\n    bind:this={scrollerEl}\n    data-slot=\"overlay-scroll-viewport\"\n    class=\"overlay-scroll__inner h-full overflow-x-hidden overflow-y-auto\"\n    onscroll={onScroll}\n  >\n    {@render children?.()}\n  </div>\n  <div\n    bind:this={thumbEl}\n    data-slot=\"overlay-scroll-thumb\"\n    class={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    aria-hidden=\"true\"\n    style:width=\"{thumbWidth}px\"\n    style:right=\"var(--ovs-thumb-right, {thumbOffset}px)\"\n    style:height=\"{thumbHeight}px\"\n    style:transform=\"translateY({thumbTop}px)\"\n    onpointerdown={onThumbPointerDown}\n  ></div>\n</div>\n\n<style>\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  /* NOTE: Svelte scopes `:hover` on the root class to this component's own\n     markup, matching the Vue `scoped` behaviour. */\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": "~/src/lib/components/ui/overlay-scroll/OverlayScroll.svelte"
    },
    {
      "path": "packages/registry-svelte/components/overlay-scroll/index.ts",
      "content": "export { default as OverlayScroll, type OverlayScrollHandle, type OverlayScrollProps } from './OverlayScroll.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/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"
  ]
}