Skip to content

Instantly share code, notes, and snippets.

@0kzh
Created May 10, 2024 16:24
Show Gist options
  • Save 0kzh/3378a10d5e789796db5143f5d07c3343 to your computer and use it in GitHub Desktop.
Save 0kzh/3378a10d5e789796db5143f5d07c3343 to your computer and use it in GitHub Desktop.
// from https://kelvinzhang.com/playground/scroll-area
import { CSSProperties, useCallback, useEffect, useRef, useState } from "react";
import styled from 'styled-components';
type ScrollAreaProps = {
children: React.ReactNode;
showOverflowIndicator?: boolean;
hideScrollbar?: boolean;
indicatorColor?: CSSProperties["background"];
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
} & React.ComponentProps<"div">;
const ScrollContainer = styled.div`
position: relative;
`;
const InnerScrollContainer = styled.div<{ hideScrollbar: boolean }>`
display: flex;
overflow: auto;
height: 100%;
width: 100%;
${props => props.hideScrollbar ? `
::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
` : ''}
`;
const Indicator = styled.div<{ visible: boolean; position?: "start" | "end"; indicatorColor?: CSSProperties["background"] }>`
pointer-events: none;
position: absolute;
top: 0;
${props => props.position === 'start' ? 'left: 0;' : 'right: 0;'}
width: 64px;
height: 100%;
background: ${props => props.position === 'start' ? `linear-gradient(to right, ${props.indicatorColor}, transparent)` : `linear-gradient(to left, ${props.indicatorColor}, transparent)`};
opacity: ${props => props.visible ? '100%' : '0'};
${props => props.position === 'start' ? 'border-left: 1px solid #252525;' : 'border-right: 1px solid #252525;'}
transition: opacity 300ms ease-in-out;
`;
const ScrollArea: React.FC<ScrollAreaProps> = ({
children,
showOverflowIndicator = false,
hideScrollbar = true,
indicatorColor = 'black',
onScroll,
...props
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false);
const [showStartIndicator, setShowStartIndicator] = useState(false);
const [showEndIndicator, setShowEndIndicator] = useState(false);
const checkOverflow = (el: HTMLDivElement) => {
const isOverflowing =
el.clientWidth < el.scrollWidth || el.clientHeight < el.scrollHeight;
return isOverflowing;
};
const handleScroll = useCallback(() => {
if (containerRef.current) {
const {
scrollLeft,
scrollTop,
scrollWidth,
scrollHeight,
clientWidth,
clientHeight,
} = containerRef.current;
const isAtStart = scrollLeft === 0;
const isAtEnd = Math.abs(scrollLeft + clientWidth - scrollWidth) <= 1;
setShowStartIndicator(!isAtStart);
setShowEndIndicator(!isAtEnd);
if (onScroll) {
onScroll({
currentTarget: containerRef.current,
} as React.UIEvent<HTMLDivElement>);
}
}
}, [onScroll]);
useEffect(() => {
const handleOverflow = () => {
if (containerRef.current) {
const isOverflowing = checkOverflow(containerRef.current);
setOverflowing(isOverflowing);
handleScroll();
}
};
handleOverflow();
const resizeObserver = new ResizeObserver(handleOverflow);
const currentContainer = containerRef.current; // Copy the current ref to a variable
if (currentContainer) {
resizeObserver.observe(currentContainer);
currentContainer.addEventListener("scroll", handleScroll);
}
return () => {
if (currentContainer) { // Use the copied ref variable in the cleanup function
currentContainer.removeEventListener("scroll", handleScroll);
}
resizeObserver.disconnect();
};
}, [children, showOverflowIndicator, handleScroll]);
return (
<ScrollContainer>
<InnerScrollContainer ref={containerRef} hideScrollbar={hideScrollbar} {...props}>
{children}
</InnerScrollContainer>
{showOverflowIndicator && overflowing && (
<>
<Indicator visible={showStartIndicator} position="start" indicatorColor={indicatorColor} />
<Indicator visible={showEndIndicator} position="end" indicatorColor={indicatorColor} />
</>
)}
</ScrollContainer>
);
};
export default ScrollArea;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment