import { type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useRef, useState, } from 'react' type Axis = 'x' | 'y' interface AxisState { visible: boolean dragging: boolean thumbSize: number thumbOffset: number } interface ScrollbarState { x: AxisState y: AxisState } interface ScrollbarProps { children: ReactNode className?: string minThumbSize?: number } interface DragState { axis: Axis pointerId: number pointerOffset: number } const INITIAL_AXIS_STATE: AxisState = { visible: false, dragging: false, thumbSize: 0, thumbOffset: 0, } const INITIAL_STATE: ScrollbarState = { x: INITIAL_AXIS_STATE, y: INITIAL_AXIS_STATE, } function Scrollbar({ children, className = '', minThumbSize = 28, }: ScrollbarProps) { const viewportRef = useRef(null) const trackXRef = useRef(null) const trackYRef = useRef(null) const dragStateRef = useRef(null) const [scrollbar, setScrollbar] = useState(INITIAL_STATE) const updateScrollbar = useCallback(() => { const viewport = viewportRef.current const trackX = trackXRef.current const trackY = trackYRef.current if (!viewport || !trackX || !trackY) return const { clientWidth, clientHeight, scrollWidth, scrollHeight, scrollLeft, scrollTop, } = viewport const hasOverflowX = scrollWidth - clientWidth > 1 const hasOverflowY = scrollHeight - clientHeight > 1 const nextX: AxisState = hasOverflowX && clientWidth > 0 && trackX.clientWidth > 0 ? (() => { const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackX.clientWidth) const maxThumbOffset = Math.max(0, trackX.clientWidth - thumbSize) const scrollRange = Math.max(1, scrollWidth - clientWidth) return { visible: true, dragging: dragStateRef.current?.axis === 'x', thumbSize, thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset, } })() : { ...INITIAL_AXIS_STATE } const nextY: AxisState = hasOverflowY && clientHeight > 0 && trackY.clientHeight > 0 ? (() => { const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackY.clientHeight) const maxThumbOffset = Math.max(0, trackY.clientHeight - thumbSize) const scrollRange = Math.max(1, scrollHeight - clientHeight) return { visible: true, dragging: dragStateRef.current?.axis === 'y', thumbSize, thumbOffset: (scrollTop / scrollRange) * maxThumbOffset, } })() : { ...INITIAL_AXIS_STATE } setScrollbar({ x: nextX, y: nextY }) }, [minThumbSize]) useEffect(() => { const viewport = viewportRef.current const trackX = trackXRef.current const trackY = trackYRef.current if (!viewport || !trackX || !trackY) return let rafId = 0 const scheduleUpdate = () => { cancelAnimationFrame(rafId) rafId = window.requestAnimationFrame(() => { updateScrollbar() }) } updateScrollbar() scheduleUpdate() const resizeObserver = new ResizeObserver(() => { scheduleUpdate() }) const mutationObserver = new MutationObserver(() => { scheduleUpdate() }) resizeObserver.observe(viewport) resizeObserver.observe(trackX) resizeObserver.observe(trackY) Array.from(viewport.children).forEach((child) => resizeObserver.observe(child)) mutationObserver.observe(viewport, { childList: true, subtree: true, attributes: true, characterData: true, }) viewport.addEventListener('scroll', scheduleUpdate, { passive: true }) window.addEventListener('resize', scheduleUpdate) return () => { cancelAnimationFrame(rafId) resizeObserver.disconnect() mutationObserver.disconnect() viewport.removeEventListener('scroll', scheduleUpdate) window.removeEventListener('resize', scheduleUpdate) } }, [children, updateScrollbar]) useEffect(() => { const stopDragging = () => { dragStateRef.current = null document.body.style.userSelect = '' setScrollbar((previous) => ({ x: { ...previous.x, dragging: false }, y: { ...previous.y, dragging: false }, })) } window.addEventListener('pointerup', stopDragging) window.addEventListener('pointercancel', stopDragging) return () => { window.removeEventListener('pointerup', stopDragging) window.removeEventListener('pointercancel', stopDragging) } }, []) const syncScrollFromThumbOffset = (axis: Axis, nextThumbOffset: number) => { const viewport = viewportRef.current const track = axis === 'x' ? trackXRef.current : trackYRef.current const axisState = scrollbar[axis] if (!viewport || !track) return const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize) if (maxThumbOffset <= 0) { if (axis === 'x') viewport.scrollLeft = 0 else viewport.scrollTop = 0 return } const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset if (axis === 'x') { viewport.scrollLeft = progress * Math.max(0, viewport.scrollWidth - viewport.clientWidth) return } viewport.scrollTop = progress * Math.max(0, viewport.scrollHeight - viewport.clientHeight) } const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { if (!scrollbar[axis].visible) return const rect = event.currentTarget.getBoundingClientRect() dragStateRef.current = { axis, pointerId: event.pointerId, pointerOffset: axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top, } setScrollbar((previous) => ({ x: { ...previous.x, dragging: axis === 'x' }, y: { ...previous.y, dragging: axis === 'y' }, })) document.body.style.userSelect = 'none' event.currentTarget.setPointerCapture(event.pointerId) event.stopPropagation() event.preventDefault() } const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent) => { const dragState = dragStateRef.current const track = axis === 'x' ? trackXRef.current : trackYRef.current if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId || !track) return const rect = track.getBoundingClientRect() const nextThumbOffset = axis === 'x' ? event.clientX - rect.left - dragState.pointerOffset : event.clientY - rect.top - dragState.pointerOffset syncScrollFromThumbOffset(axis, nextThumbOffset) } const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent) => { const dragState = dragStateRef.current if (!dragState || dragState.axis !== axis || dragState.pointerId !== event.pointerId) return dragStateRef.current = null document.body.style.userSelect = '' setScrollbar((previous) => ({ x: { ...previous.x, dragging: false }, y: { ...previous.y, dragging: false }, })) event.currentTarget.releasePointerCapture?.(event.pointerId) } const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent) => { if (!scrollbar[axis].visible || event.target !== event.currentTarget) return const rect = event.currentTarget.getBoundingClientRect() const nextThumbOffset = axis === 'x' ? event.clientX - rect.left - scrollbar.x.thumbSize / 2 : event.clientY - rect.top - scrollbar.y.thumbSize / 2 syncScrollFromThumbOffset(axis, nextThumbOffset) } const hasCorner = scrollbar.x.visible && scrollbar.y.visible return (
{children}
{scrollbar.y.visible ? (
) : null}
{scrollbar.x.visible ? (
) : null}
) } export default Scrollbar