Overlay Scroll
Vue layoutSlack-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-*`).
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/overlay-scroll.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/overlay-scroll.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/overlay-scroll.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/overlay-scroll.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/overlay-scroll
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
thumbWidth Thumb width in px when idle. Expands to ~2x on hover / drag. | number | 4 | optional |
thumbOffset Right offset of the thumb from the inner edge, in px. | number | 2 | optional |
idleHideMs ms of scroll inactivity before the thumb fades. | number | 800 | optional |
draggable Allow dragging the thumb to scroll. | boolean | true | optional |
class Tailwind classes forwarded to the outer wrapper. | string | — | optional |
Files (2)
-
app/components/ui/overlay-scroll/OverlayScroll.vue 7.6 kB
<script setup lang="ts"> import { onMounted, onScopeDispose, ref } from 'vue' import { cn } from '@/lib/utils' // Slack-style overlay scrollbar. Hides the native scrollbar entirely so the // scrolled content uses the full container width (no reservation), then draws // a thin auto-fading thumb absolutely positioned on top. Drag-to-scroll on the // thumb is supported via Pointer Events (mouse + touch + pen). Vertical only. // // Give the component a bounded height via the parent (e.g. `flex-1 min-h-0` // inside a flex column, or a fixed `h-*` / `max-h-*`). Without a bound it // expands to its content and the thumb is hidden. const props = withDefaults( defineProps<{ // Thumb width in px when idle. Expands to ~2x on hover / drag. thumbWidth?: number // Right offset of the thumb from the inner edge, in px. thumbOffset?: number // ms of scroll inactivity before the thumb fades. idleHideMs?: number // Allow dragging the thumb to scroll. draggable?: boolean // Tailwind classes forwarded to the outer wrapper. class?: string }>(), { thumbWidth: 4, thumbOffset: 2, idleHideMs: 800, draggable: true, }, ) const scrollerEl = ref<HTMLElement | null>(null) const thumbEl = ref<HTMLElement | null>(null) const thumbHeight = ref(0) const thumbTop = ref(0) const showThumb = ref(false) const isHovered = ref(false) const isDragging = ref(false) let hideTimer: ReturnType<typeof setTimeout> | null = null const recompute = () => { const el = scrollerEl.value if (!el) return const ratio = el.clientHeight / el.scrollHeight if (!Number.isFinite(ratio) || ratio >= 1) { thumbHeight.value = 0 return } thumbHeight.value = Math.max(24, el.clientHeight * ratio) const maxScroll = el.scrollHeight - el.clientHeight const maxThumb = el.clientHeight - thumbHeight.value thumbTop.value = maxScroll > 0 ? (el.scrollTop / maxScroll) * maxThumb : 0 } const flashThumb = () => { if (thumbHeight.value === 0) return showThumb.value = true if (hideTimer) clearTimeout(hideTimer) hideTimer = setTimeout(() => { if (!isHovered.value && !isDragging.value) showThumb.value = false }, props.idleHideMs) } const onScroll = () => { recompute() flashThumb() } const onEnter = () => { isHovered.value = true recompute() if (thumbHeight.value > 0) showThumb.value = true } const onLeave = () => { isHovered.value = false if (isDragging.value) return if (hideTimer) clearTimeout(hideTimer) showThumb.value = false } // Pointer Events cover mouse + touch + pen on every modern browser // (Chrome 55+, Firefox 59+, Safari 13+, Edge). setPointerCapture keeps the // drag alive even if the pointer leaves the thumb, matching native feel. let activePointerId: number | null = null let dragStartY = 0 let dragStartScrollTop = 0 const onPointerMove = (e: PointerEvent) => { if (activePointerId !== e.pointerId) return const el = scrollerEl.value if (!el) return const maxScroll = el.scrollHeight - el.clientHeight const maxThumb = el.clientHeight - thumbHeight.value if (maxThumb <= 0) return const scrollRatio = maxScroll / maxThumb el.scrollTop = dragStartScrollTop + (e.clientY - dragStartY) * scrollRatio } const endDrag = (e?: PointerEvent) => { if (e && activePointerId !== e.pointerId) return isDragging.value = false if (thumbEl.value && activePointerId !== null) { try { thumbEl.value.releasePointerCapture(activePointerId) } catch { // pointer may already be released; ignore } } activePointerId = null thumbEl.value?.removeEventListener('pointermove', onPointerMove) thumbEl.value?.removeEventListener('pointerup', endDrag) thumbEl.value?.removeEventListener('pointercancel', endDrag) if (!isHovered.value) showThumb.value = false } const onThumbPointerDown = (e: PointerEvent) => { if (!props.draggable || !scrollerEl.value || !thumbEl.value) return if (e.pointerType === 'mouse' && e.button !== 0) return e.preventDefault() isDragging.value = true activePointerId = e.pointerId dragStartY = e.clientY dragStartScrollTop = scrollerEl.value.scrollTop thumbEl.value.setPointerCapture(e.pointerId) thumbEl.value.addEventListener('pointermove', onPointerMove) thumbEl.value.addEventListener('pointerup', endDrag) thumbEl.value.addEventListener('pointercancel', endDrag) } let resizeObserver: ResizeObserver | null = null let mutationObserver: MutationObserver | null = null onMounted(() => { recompute() if (!scrollerEl.value) return resizeObserver = new ResizeObserver(recompute) resizeObserver.observe(scrollerEl.value) const inner = scrollerEl.value.firstElementChild as HTMLElement | null if (inner) { resizeObserver.observe(inner) mutationObserver = new MutationObserver(recompute) mutationObserver.observe(inner, { childList: true, subtree: true }) } }) onScopeDispose(() => { resizeObserver?.disconnect() mutationObserver?.disconnect() if (hideTimer) clearTimeout(hideTimer) endDrag() }) defineExpose({ // Underlying scroller DOM element; call .scrollTo() on it from a parent. scrollerEl, // Force thumb recalc after a non-DOM size change. recompute, }) </script> <template> <div data-uipkge data-slot="overlay-scroll" :class="cn('overlay-scroll relative', props.class)" @mouseenter="onEnter" @mouseleave="onLeave" > <div ref="scrollerEl" data-slot="overlay-scroll-viewport" class="overlay-scroll__inner h-full overflow-x-hidden overflow-y-auto" @scroll="onScroll" > <slot /> </div> <div ref="thumbEl" data-slot="overlay-scroll-thumb" class="overlay-scroll__thumb" :class="{ 'overlay-scroll__thumb--visible': showThumb, 'overlay-scroll__thumb--dragging': isDragging, 'overlay-scroll__thumb--draggable': draggable, }" :style="{ width: `${thumbWidth}px`, /* Read the right offset from --ovs-thumb-right when an ancestor sets it (e.g. Sidebar pushes the thumb away from SidebarRail), otherwise fall back to the prop. Lets the same primitive sit flush in a card AND clear a rail without per-call config. */ right: `var(--ovs-thumb-right, ${thumbOffset}px)`, height: `${thumbHeight}px`, transform: `translateY(${thumbTop}px)`, }" @pointerdown="onThumbPointerDown" /> </div> </template> <style scoped> .overlay-scroll__inner { scrollbar-width: none; -ms-overflow-style: none; /* Stop wheel events from chaining to the page once the inner scroller hits its top/bottom. Without this, scrolling a long activity feed past its last item keeps scrolling the surrounding page — confusing when the inner region is a clearly bounded card. */ overscroll-behavior: contain; } .overlay-scroll__inner::-webkit-scrollbar { display: none; width: 0; height: 0; } .overlay-scroll__thumb { position: absolute; top: 0; border-radius: 2px; background: var(--muted-foreground); opacity: 0; pointer-events: none; touch-action: none; user-select: none; -webkit-user-select: none; transition: opacity 0.2s ease, background-color 0.15s, width 0.12s ease; will-change: transform, opacity; } .overlay-scroll__thumb--draggable { cursor: pointer; } .overlay-scroll__thumb--visible { opacity: 0.4; pointer-events: auto; } .overlay-scroll:hover .overlay-scroll__thumb--visible { opacity: 0.6; } .overlay-scroll:hover .overlay-scroll__thumb--draggable:hover { opacity: 0.8; width: 8px !important; background: var(--foreground); } .overlay-scroll__thumb--dragging { opacity: 1 !important; background: var(--foreground) !important; width: 8px !important; } </style> -
app/components/ui/overlay-scroll/index.ts 0.1 kB
export { default as OverlayScroll } from './OverlayScroll.vue'
Raw manifest: https://uipkge.dev/r/vue/overlay-scroll.json