Files
portfolio-website/src/ScrollArea.tsx
T
Zacharias-Brohn 5c123db557 initial commit
2026-01-14 10:46:21 +01:00

238 lines
8.2 KiB
TypeScript

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<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(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<number | null>(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 (
<div
className={`relative ${className}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
ref={containerRef}
className="h-full overflow-y-auto overflow-x-hidden"
onScroll={handleScroll}
>
{children}
</div>
{/* Custom scrollbar track */}
<div
className="absolute top-0 w-2 h-full cursor-pointer"
onClick={handleTrackClick}
onMouseEnter={() => setIsThumbHovered(true)}
onMouseLeave={() => setIsThumbHovered(false)}
style={{
right: `-${scrollbarOffset}px`,
opacity: showScrollbar ? 1 : 0,
transition: "opacity 200ms ease-out",
pointerEvents: showScrollbar ? "auto" : "none",
}}
>
{/* Scrollbar thumb */}
<div
ref={thumbRef}
className="absolute right-0.5 rounded-full bg-gray-400/60 dark:bg-slate-500/60 hover:bg-gray-500/80 dark:hover:bg-slate-400/80 cursor-grab active:cursor-grabbing"
style={{
width: isThumbExpanded ? "6px" : "3px",
height: `${thumbHeight}px`,
top: `${thumbTop}px`,
transition: isDragging
? "width 150ms ease"
: "width 150ms ease, background-color 150ms ease",
}}
onMouseDown={handleThumbMouseDown}
/>
</div>
</div>
);
}