UIPackage

Overlay Scroll

React layout
Edit on GitHub

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-*`).

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/overlay-scroll.json

Or with the named registry: npx shadcn@latest add @uipkge-react/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
className

Tailwind classes forwarded to the outer wrapper.

string optional
children React.ReactNode optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

OverlayScrollHandle
interface OverlayScrollHandle {
  /** Underlying scroller DOM element; call .scrollTo() on it from a parent. */
  scrollerEl: HTMLElement | null
  /** Force thumb recalc after a non-DOM size change. */
  recompute: () => void
}

Files (2)

  • components/ui/overlay-scroll/overlay-scroll.tsx 10.4 kB
    'use client'
    
    import * as React from 'react'
    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.
    
    // Ported from OverlayScroll.vue's <style scoped> block. Injected once so the
    // component ships self-contained (Tailwind has no equivalent overlay-scrollbar
    // utility). Class names match the Vue source 1:1.
    const overlayScrollCss = `
    .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;
    }
    `
    
    function OverlayScrollStyle() {
      return <style dangerouslySetInnerHTML={{ __html: overlayScrollCss }} />
    }
    
    export interface OverlayScrollHandle {
      /** Underlying scroller DOM element; call .scrollTo() on it from a parent. */
      scrollerEl: HTMLElement | null
      /** Force thumb recalc after a non-DOM size change. */
      recompute: () => void
    }
    
    export interface OverlayScrollProps {
      /** 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. */
      className?: string
      children?: React.ReactNode
    }
    
    const OverlayScroll = React.forwardRef<OverlayScrollHandle, OverlayScrollProps>(
      ({ thumbWidth = 4, thumbOffset = 2, idleHideMs = 800, draggable = true, className, children }, ref) => {
        const scrollerRef = React.useRef<HTMLDivElement | null>(null)
        const thumbRef = React.useRef<HTMLDivElement | null>(null)
    
        const [thumbHeight, setThumbHeight] = React.useState(0)
        const [thumbTop, setThumbTop] = React.useState(0)
        const [showThumb, setShowThumb] = React.useState(false)
        const [isDragging, setIsDragging] = React.useState(false)
    
        // Refs that hold the latest value for callbacks bound once (no re-bind on
        // every render). Mirrors the Vue refs that the closures read directly.
        const isHoveredRef = React.useRef(false)
        const isDraggingRef = React.useRef(false)
        const thumbHeightRef = React.useRef(0)
        const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
        const idleHideMsRef = React.useRef(idleHideMs)
        const draggableRef = React.useRef(draggable)
    
        React.useEffect(() => {
          idleHideMsRef.current = idleHideMs
        }, [idleHideMs])
        React.useEffect(() => {
          draggableRef.current = draggable
        }, [draggable])
    
        const recompute = React.useCallback(() => {
          const el = scrollerRef.current
          if (!el) return
          const ratio = el.clientHeight / el.scrollHeight
          if (!Number.isFinite(ratio) || ratio >= 1) {
            thumbHeightRef.current = 0
            setThumbHeight(0)
            return
          }
          const h = Math.max(24, el.clientHeight * ratio)
          thumbHeightRef.current = h
          setThumbHeight(h)
          const maxScroll = el.scrollHeight - el.clientHeight
          const maxThumb = el.clientHeight - h
          setThumbTop(maxScroll > 0 ? (el.scrollTop / maxScroll) * maxThumb : 0)
        }, [])
    
        const flashThumb = React.useCallback(() => {
          if (thumbHeightRef.current === 0) return
          setShowThumb(true)
          if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
          hideTimerRef.current = setTimeout(() => {
            if (!isHoveredRef.current && !isDraggingRef.current) setShowThumb(false)
          }, idleHideMsRef.current)
        }, [])
    
        const onScroll = React.useCallback(() => {
          recompute()
          flashThumb()
        }, [recompute, flashThumb])
    
        const onEnter = React.useCallback(() => {
          isHoveredRef.current = true
          recompute()
          if (thumbHeightRef.current > 0) setShowThumb(true)
        }, [recompute])
    
        const onLeave = React.useCallback(() => {
          isHoveredRef.current = false
          if (isDraggingRef.current) return
          if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
          setShowThumb(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.
        const activePointerIdRef = React.useRef<number | null>(null)
        const dragStartYRef = React.useRef(0)
        const dragStartScrollTopRef = React.useRef(0)
    
        const onPointerMove = React.useCallback((e: PointerEvent) => {
          if (activePointerIdRef.current !== e.pointerId) return
          const el = scrollerRef.current
          if (!el) return
          const maxScroll = el.scrollHeight - el.clientHeight
          const maxThumb = el.clientHeight - thumbHeightRef.current
          if (maxThumb <= 0) return
          const scrollRatio = maxScroll / maxThumb
          el.scrollTop = dragStartScrollTopRef.current + (e.clientY - dragStartYRef.current) * scrollRatio
        }, [])
    
        const endDrag = React.useCallback(
          (e?: PointerEvent) => {
            if (e && activePointerIdRef.current !== e.pointerId) return
            isDraggingRef.current = false
            setIsDragging(false)
            if (thumbRef.current && activePointerIdRef.current !== null) {
              try {
                thumbRef.current.releasePointerCapture(activePointerIdRef.current)
              } catch {
                // pointer may already be released; ignore
              }
            }
            activePointerIdRef.current = null
            thumbRef.current?.removeEventListener('pointermove', onPointerMove)
            thumbRef.current?.removeEventListener('pointerup', endDrag)
            thumbRef.current?.removeEventListener('pointercancel', endDrag)
            if (!isHoveredRef.current) setShowThumb(false)
          },
          [onPointerMove],
        )
    
        const onThumbPointerDown = React.useCallback(
          (e: React.PointerEvent) => {
            if (!draggableRef.current || !scrollerRef.current || !thumbRef.current) return
            if (e.pointerType === 'mouse' && e.button !== 0) return
            e.preventDefault()
            isDraggingRef.current = true
            setIsDragging(true)
            activePointerIdRef.current = e.pointerId
            dragStartYRef.current = e.clientY
            dragStartScrollTopRef.current = scrollerRef.current.scrollTop
            thumbRef.current.setPointerCapture(e.pointerId)
            thumbRef.current.addEventListener('pointermove', onPointerMove)
            thumbRef.current.addEventListener('pointerup', endDrag)
            thumbRef.current.addEventListener('pointercancel', endDrag)
          },
          [onPointerMove, endDrag],
        )
    
        React.useEffect(() => {
          recompute()
          const scroller = scrollerRef.current
          if (!scroller) return
    
          const resizeObserver = new ResizeObserver(recompute)
          resizeObserver.observe(scroller)
    
          const inner = scroller.firstElementChild as HTMLElement | null
          let mutationObserver: MutationObserver | null = null
          if (inner) {
            resizeObserver.observe(inner)
            mutationObserver = new MutationObserver(recompute)
            mutationObserver.observe(inner, { childList: true, subtree: true })
          }
    
          return () => {
            resizeObserver.disconnect()
            mutationObserver?.disconnect()
            if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
            endDrag()
          }
        }, [recompute, endDrag])
    
        React.useImperativeHandle(
          ref,
          () => ({
            get scrollerEl() {
              return scrollerRef.current
            },
            recompute,
          }),
          [recompute],
        )
    
        return (
          <div
            data-uipkge=""
            data-slot="overlay-scroll"
            className={cn('overlay-scroll relative', className)}
            onMouseEnter={onEnter}
            onMouseLeave={onLeave}
          >
            <OverlayScrollStyle />
            <div
              ref={scrollerRef}
              data-slot="overlay-scroll-viewport"
              className="overlay-scroll__inner h-full overflow-x-hidden overflow-y-auto"
              onScroll={onScroll}
            >
              {children}
            </div>
            <div
              ref={thumbRef}
              data-slot="overlay-scroll-thumb"
              className={cn(
                'overlay-scroll__thumb',
                showThumb && 'overlay-scroll__thumb--visible',
                isDragging && 'overlay-scroll__thumb--dragging',
                draggable && 'overlay-scroll__thumb--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)`,
              }}
              onPointerDown={onThumbPointerDown}
            />
          </div>
        )
      },
    )
    OverlayScroll.displayName = 'OverlayScroll'
    
    export { OverlayScroll }
  • components/ui/overlay-scroll/index.ts 0.1 kB
    export { OverlayScroll, type OverlayScrollProps, type OverlayScrollHandle } from './overlay-scroll'

Raw manifest: https://react.uipkge.dev/r/react/overlay-scroll.json