import React, { useRef, useState, useEffect, useCallback } from "react"; interface ScrollAreaProps { children: React.ReactNode; className?: string; fadeDelay?: number; // ms before scrollbar fades out scrollbarOffset?: number; // px to offset scrollbar to the right (into padding area) } export default function ScrollArea({ children, className = "", fadeDelay = 1000, scrollbarOffset = 0, }: ScrollAreaProps) { const containerRef = useRef(null); const thumbRef = useRef(null); const [isHovered, setIsHovered] = useState(false); const [isScrolling, setIsScrolling] = useState(false); const [isDragging, setIsDragging] = useState(false); const [isThumbHovered, setIsThumbHovered] = useState(false); const [thumbHeight, setThumbHeight] = useState(0); const [thumbTop, setThumbTop] = useState(0); const [canScroll, setCanScroll] = useState(false); const fadeTimeoutRef = useRef(null); const dragStartRef = useRef({ y: 0, scrollTop: 0 }); // Check if content is scrollable and calculate thumb dimensions const updateScrollbar = useCallback(() => { const container = containerRef.current; if (!container) return; const { scrollHeight, clientHeight, scrollTop } = container; const hasScroll = scrollHeight > clientHeight; setCanScroll(hasScroll); if (hasScroll) { // Calculate thumb height as a proportion of visible content const ratio = clientHeight / scrollHeight; const minThumbHeight = 30; const calculatedHeight = Math.max( clientHeight * ratio, minThumbHeight, ); setThumbHeight(calculatedHeight); // Calculate thumb position const maxScrollTop = scrollHeight - clientHeight; const scrollRatio = scrollTop / maxScrollTop; const maxThumbTop = clientHeight - calculatedHeight; setThumbTop(scrollRatio * maxThumbTop); } }, []); // Handle scroll events const handleScroll = useCallback(() => { updateScrollbar(); setIsScrolling(true); // Clear existing timeout if (fadeTimeoutRef.current) { clearTimeout(fadeTimeoutRef.current); } // Set new timeout to hide scrollbar fadeTimeoutRef.current = window.setTimeout(() => { if (!isHovered && !isDragging) { setIsScrolling(false); } }, fadeDelay); }, [updateScrollbar, fadeDelay, isHovered, isDragging]); // Handle mouse enter/leave const handleMouseEnter = useCallback(() => { setIsHovered(true); updateScrollbar(); }, [updateScrollbar]); const handleMouseLeave = useCallback(() => { setIsHovered(false); if (!isDragging) { // Start fade timer when mouse leaves if (fadeTimeoutRef.current) { clearTimeout(fadeTimeoutRef.current); } fadeTimeoutRef.current = window.setTimeout(() => { setIsScrolling(false); }, fadeDelay); } }, [fadeDelay, isDragging]); // Handle thumb drag const handleThumbMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); dragStartRef.current = { y: e.clientY, scrollTop: containerRef.current?.scrollTop || 0, }; }, []); // Handle track click (jump to position) const handleTrackClick = useCallback((e: React.MouseEvent) => { const container = containerRef.current; const track = e.currentTarget; if (!container || e.target === thumbRef.current) return; const rect = track.getBoundingClientRect(); const clickY = e.clientY - rect.top; const trackHeight = rect.height; const ratio = clickY / trackHeight; const maxScrollTop = container.scrollHeight - container.clientHeight; container.scrollTop = ratio * maxScrollTop; }, []); // Global mouse move/up handlers for dragging useEffect(() => { if (!isDragging) return; const handleMouseMove = (e: MouseEvent) => { const container = containerRef.current; if (!container) return; const deltaY = e.clientY - dragStartRef.current.y; const { scrollHeight, clientHeight } = container; const maxScrollTop = scrollHeight - clientHeight; const trackHeight = clientHeight - thumbHeight; const scrollDelta = (deltaY / trackHeight) * maxScrollTop; container.scrollTop = dragStartRef.current.scrollTop + scrollDelta; }; const handleMouseUp = () => { setIsDragging(false); // Start fade timer after drag ends if (!isHovered) { fadeTimeoutRef.current = window.setTimeout(() => { setIsScrolling(false); }, fadeDelay); } }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [isDragging, thumbHeight, isHovered, fadeDelay]); // Update scrollbar on mount and resize useEffect(() => { updateScrollbar(); const container = containerRef.current; if (!container) return; const resizeObserver = new ResizeObserver(() => { updateScrollbar(); }); resizeObserver.observe(container); // Also observe children for content changes const mutationObserver = new MutationObserver(() => { updateScrollbar(); }); mutationObserver.observe(container, { childList: true, subtree: true, characterData: true, }); return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); }; }, [updateScrollbar]); // Cleanup timeout on unmount useEffect(() => { return () => { if (fadeTimeoutRef.current) { clearTimeout(fadeTimeoutRef.current); } }; }, []); const showScrollbar = canScroll && (isHovered || isScrolling || isDragging); const isThumbExpanded = isThumbHovered || isDragging; return (
{children}
{/* Custom scrollbar track */}
setIsThumbHovered(true)} onMouseLeave={() => setIsThumbHovered(false)} style={{ right: `-${scrollbarOffset}px`, opacity: showScrollbar ? 1 : 0, transition: "opacity 200ms ease-out", pointerEvents: showScrollbar ? "auto" : "none", }} > {/* Scrollbar thumb */}
); }