Skip to content

Commit 11e51ba

Browse files
authored
Scroll area scroll improvements part 2 (#1110)
1 parent 6a09913 commit 11e51ba

File tree

7 files changed

+257
-46
lines changed

7 files changed

+257
-46
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"./Progress": "./dist/components/Progress.js",
2828
"./Quote": "./dist/components/Quote.js",
2929
"./RadioGroup": "./dist/components/RadioGroup.js",
30+
"./ScrollArea": "./dist/components/ScrollArea.js",
3031
"./Separator": "./dist/components/Separator.js",
3132
"./Skeleton": "./dist/components/Skeleton.js",
3233
"./Strong": "./dist/components/Strong.js",

src/components/tools/SandboxEditor/SandboxEditor.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,19 @@ const SandboxEditor = ({ children, className } : SandboxProps) => {
4343
const [colorName, setColorName] = useState<AvailableColors>('plum');
4444

4545
useEffect(() => {
46-
46+
const isDarkMode = localStorage.getItem('isDarkMode') === 'true';
47+
setIsDarkMode(isDarkMode);
4748
}, []);
4849

4950
const toggleDarkMode = () => {
51+
localStorage.setItem('isDarkMode', (!isDarkMode).toString());
5052
setIsDarkMode(!isDarkMode);
5153
};
5254

5355
return <Theme
5456
appearance={isDarkMode ? 'dark' : 'light'}
5557
accentColor={colorName}
56-
className={'p-4 shadow-sm text-gray-900 h-screen border border-gray-300 bg-gray-50'}>
58+
className={'p-4 shadow-sm text-gray-900 min-h-screen border border-gray-300 bg-gray-50'}>
5759
<div className='mb-4'>
5860
{/* @ts-ignore */}
5961
<div className='flex items-center space-x-4'>

src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ const ScrollAreaRoot = ({ children, className = '', customRootClass = '', ...pro
2828
const scrollAreaHeight = scrollAreaViewportRef?.current?.scrollHeight || 0;
2929

3030
const factor = scrollAreaHeight / scrollAreaContainerHeight;
31-
const finalHeight = (scrollAreaContainerHeight / factor);
31+
let finalHeight = (scrollAreaContainerHeight / factor);
32+
33+
// cap the minimum height to 10px
34+
if (finalHeight < 24) {
35+
finalHeight = 24;
36+
}
3237

3338
if (scrollXThumbRef.current) {
3439
scrollXThumbRef.current.style.height = `${finalHeight}px`;
@@ -55,49 +60,70 @@ const ScrollAreaRoot = ({ children, className = '', customRootClass = '', ...pro
5560
}
5661
};
5762

63+
// Fast custom scroll animation
64+
const fastScrollTo = (targetScrollTop: number) => {
65+
if (!scrollAreaViewportRef.current) return;
66+
67+
const startScrollTop = scrollAreaViewportRef.current.scrollTop;
68+
const scrollDistance = targetScrollTop - startScrollTop;
69+
const duration = 150; // Fast 150ms animation
70+
const startTime = performance.now();
71+
72+
const animate = (currentTime: number) => {
73+
const elapsed = currentTime - startTime;
74+
const progress = Math.min(elapsed / duration, 1);
75+
76+
// Ease-out for smoother feel
77+
const easeProgress = 1 - Math.pow(1 - progress, 3);
78+
const currentScrollTop = startScrollTop + (scrollDistance * easeProgress);
79+
80+
if (scrollAreaViewportRef.current) {
81+
scrollAreaViewportRef.current.scrollTop = currentScrollTop;
82+
}
83+
84+
if (progress < 1) {
85+
requestAnimationFrame(animate);
86+
}
87+
};
88+
89+
requestAnimationFrame(animate);
90+
};
91+
5892
const scrollToPosition = (position: number) => {
5993
const scrollAreaContainerHeight = scrollAreaViewportRef?.current?.clientHeight || 0;
6094

61-
6295
// FUll height
6396
const scrollAreaHeight = scrollAreaViewportRef?.current?.scrollHeight || 0;
64-
65-
const factor = scrollAreaHeight / scrollAreaContainerHeight
6697

98+
const factor = scrollAreaHeight / scrollAreaContainerHeight;
6799

68100
if (scrollXThumbRef.current) {
69-
const thumbPositionStart = scrollXThumbRef.current.getBoundingClientRect().top
70-
const thumbPositionEnd = thumbPositionStart + scrollXThumbRef.current.clientHeight
101+
const thumbPositionStart = scrollXThumbRef.current.getBoundingClientRect().top;
102+
const thumbPositionEnd = thumbPositionStart + scrollXThumbRef.current.clientHeight;
71103
const scrollThumbHeight = scrollXThumbRef.current?.clientHeight || 0;
72-
104+
73105
if (position > thumbPositionStart && position < thumbPositionEnd) {
74106
return;
75107
}
76108

77-
78109
if (position < thumbPositionStart) {
79-
// scroll to top
80-
scrollAreaViewportRef.current?.scrollTo({
81-
top: scrollAreaViewportRef.current.scrollTop - (scrollThumbHeight * factor),
82-
})
110+
// scroll to top - fast custom animation
111+
const targetScrollTop = scrollAreaViewportRef.current!.scrollTop - (scrollThumbHeight * factor);
112+
fastScrollTo(targetScrollTop);
83113
}
84114

85115
if (position > thumbPositionEnd) {
86-
// scroll to bottom
87-
scrollAreaViewportRef.current?.scrollTo({
88-
top: scrollAreaViewportRef.current.scrollTop + (scrollThumbHeight * factor),
89-
})
90-
116+
// scroll to bottom - fast custom animation
117+
const targetScrollTop = scrollAreaViewportRef.current!.scrollTop + (scrollThumbHeight * factor);
118+
fastScrollTo(targetScrollTop);
91119
}
92120
}
121+
};
93122

94-
}
95-
96-
const handleScrollbarClick = (e: { clientY: any; }) => {
97-
const clientClickY = e.clientY
98-
scrollToPosition(clientClickY)
99-
100-
}
123+
const handleScrollbarClick = (e: { clientY: any; }) => {
124+
const clientClickY = e.clientY;
125+
scrollToPosition(clientClickY);
126+
};
101127

102128
return <ScrollAreaContext.Provider value={{ rootClass, scrollXThumbRef, scrollAreaViewportRef, handleScroll, handleScrollbarClick }}>
103129
<div className={clsx(rootClass, className)} {...props} >{children}</div>
Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,90 @@
11
'use client';
22

3-
import React, { useContext } from 'react';
3+
import React, { useContext, useRef, useCallback } from 'react';
44
import { ScrollAreaContext } from '../context/ScrollAreaContext';
55
import clsx from 'clsx';
66

77
const ScrollAreaScrollbar = ({ children, className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => {
8-
const { rootClass, handleScrollbarClick } = useContext(ScrollAreaContext);
9-
return <div className={clsx(rootClass + '-scrollbar', className)} {...props} onClick={handleScrollbarClick}>{children}</div>;
8+
const { rootClass, handleScrollbarClick, scrollXThumbRef } = useContext(ScrollAreaContext);
9+
const intervalRef = useRef<NodeJS.Timeout | null>(null);
10+
const isScrollingRef = useRef(false);
11+
const mousePositionRef = useRef<number>(0);
12+
13+
const shouldContinueScrolling = useCallback((mouseY: number): boolean => {
14+
if (!scrollXThumbRef?.current) return false;
15+
16+
const thumbRect = scrollXThumbRef.current.getBoundingClientRect();
17+
const thumbStart = thumbRect.top;
18+
const thumbEnd = thumbRect.bottom;
19+
20+
// Stop if mouse is within thumb bounds
21+
if (mouseY >= thumbStart && mouseY <= thumbEnd) {
22+
return false;
23+
}
24+
25+
return true;
26+
}, [scrollXThumbRef]);
27+
28+
const startContinuousScroll = useCallback((e: React.MouseEvent) => {
29+
if (!handleScrollbarClick) return;
30+
31+
e.preventDefault();
32+
mousePositionRef.current = e.clientY;
33+
34+
// Initial scroll
35+
handleScrollbarClick({ clientY: e.clientY });
36+
37+
// Start continuous scrolling after a brief delay
38+
setTimeout(() => {
39+
if (isScrollingRef.current) {
40+
intervalRef.current = setInterval(() => {
41+
if (isScrollingRef.current && shouldContinueScrolling(mousePositionRef.current)) {
42+
handleScrollbarClick({ clientY: mousePositionRef.current });
43+
} else {
44+
// Stop scrolling if thumb reached mouse position
45+
stopContinuousScroll();
46+
}
47+
}, 50); // Scroll every 50ms
48+
}
49+
}, 300); // 300ms delay before continuous scrolling starts
50+
51+
isScrollingRef.current = true;
52+
}, [handleScrollbarClick, shouldContinueScrolling]);
53+
54+
const stopContinuousScroll = useCallback(() => {
55+
isScrollingRef.current = false;
56+
if (intervalRef.current) {
57+
clearInterval(intervalRef.current);
58+
intervalRef.current = null;
59+
}
60+
}, []);
61+
62+
// Global mouse up listener
63+
React.useEffect(() => {
64+
const handleMouseUp = () => stopContinuousScroll();
65+
const handleMouseLeave = () => stopContinuousScroll();
66+
67+
document.addEventListener('mouseup', handleMouseUp);
68+
document.addEventListener('mouseleave', handleMouseLeave);
69+
70+
return () => {
71+
document.removeEventListener('mouseup', handleMouseUp);
72+
document.removeEventListener('mouseleave', handleMouseLeave);
73+
stopContinuousScroll(); // Cleanup on unmount
74+
};
75+
}, [stopContinuousScroll]);
76+
77+
return (
78+
<div
79+
className={clsx(rootClass + '-scrollbar', className)}
80+
onMouseDown={startContinuousScroll}
81+
onMouseUp={stopContinuousScroll}
82+
onMouseLeave={stopContinuousScroll}
83+
{...props}
84+
>
85+
{children}
86+
</div>
87+
);
1088
};
1189

1290
export default ScrollAreaScrollbar;
Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,97 @@
11
'use client';
22

3-
import React, { useContext } from 'react';
3+
import React, { useContext, useRef, useCallback } from 'react';
44
import { ScrollAreaContext } from '../context/ScrollAreaContext';
55
import clsx from 'clsx';
66

77
const ScrollAreaThumb = ({ children, className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => {
8-
const { rootClass, scrollXThumbRef } = useContext(ScrollAreaContext);
9-
return <div ref={scrollXThumbRef} className={clsx(rootClass + '-thumb', className)} {...props} >{children}</div>;
8+
const { rootClass, scrollXThumbRef, scrollAreaViewportRef } = useContext(ScrollAreaContext);
9+
const isDraggingRef = useRef(false);
10+
const dragStartRef = useRef({ y: 0, scrollTop: 0 });
11+
12+
const startDrag = useCallback((e: React.MouseEvent) => {
13+
if (!scrollAreaViewportRef?.current || !scrollXThumbRef?.current) return;
14+
15+
e.preventDefault();
16+
e.stopPropagation(); // Prevent scrollbar click handler
17+
18+
isDraggingRef.current = true;
19+
dragStartRef.current = {
20+
y: e.clientY,
21+
scrollTop: scrollAreaViewportRef.current.scrollTop
22+
};
23+
24+
// Add cursor style
25+
26+
document.body.style.userSelect = 'none';
27+
}, [scrollAreaViewportRef, scrollXThumbRef]);
28+
29+
const handleDrag = useCallback((e: MouseEvent) => {
30+
if (!isDraggingRef.current || !scrollAreaViewportRef?.current || !scrollXThumbRef?.current) return;
31+
32+
e.preventDefault();
33+
34+
const deltaY = e.clientY - dragStartRef.current.y;
35+
const scrollbarRect = scrollXThumbRef.current.parentElement?.getBoundingClientRect();
36+
37+
if (!scrollbarRect) return;
38+
39+
// Calculate scroll ratio
40+
const scrollAreaContainerHeight = scrollAreaViewportRef.current.clientHeight;
41+
const scrollAreaHeight = scrollAreaViewportRef.current.scrollHeight;
42+
const scrollThumbHeight = scrollXThumbRef.current.clientHeight;
43+
const scrollableTrackHeight = scrollbarRect.height - scrollThumbHeight;
44+
45+
// Convert thumb movement to content scroll
46+
const scrollRatio = deltaY / scrollableTrackHeight;
47+
const maxScroll = scrollAreaHeight - scrollAreaContainerHeight;
48+
const newScrollTop = dragStartRef.current.scrollTop + (scrollRatio * maxScroll);
49+
50+
// Clamp scroll position
51+
const clampedScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll));
52+
53+
scrollAreaViewportRef.current.scrollTop = clampedScrollTop;
54+
}, [scrollAreaViewportRef, scrollXThumbRef]);
55+
56+
const stopDrag = useCallback(() => {
57+
isDraggingRef.current = false;
58+
59+
// Reset cursor and selection
60+
document.body.style.cursor = '';
61+
document.body.style.userSelect = '';
62+
}, []);
63+
64+
// Global mouse event listeners
65+
React.useEffect(() => {
66+
const handleMouseMove = (e: MouseEvent) => handleDrag(e);
67+
const handleMouseUp = () => stopDrag();
68+
69+
if (isDraggingRef.current) {
70+
document.addEventListener('mousemove', handleMouseMove);
71+
document.addEventListener('mouseup', handleMouseUp);
72+
}
73+
74+
// Add listeners when dragging starts
75+
document.addEventListener('mousemove', handleMouseMove);
76+
document.addEventListener('mouseup', handleMouseUp);
77+
78+
return () => {
79+
document.removeEventListener('mousemove', handleMouseMove);
80+
document.removeEventListener('mouseup', handleMouseUp);
81+
stopDrag(); // Cleanup on unmount
82+
};
83+
}, [handleDrag, stopDrag]);
84+
85+
return (
86+
<div
87+
ref={scrollXThumbRef}
88+
className={clsx(rootClass + '-thumb', className)}
89+
onMouseDown={startDrag}
90+
{...props}
91+
>
92+
{children}
93+
</div>
94+
);
1095
};
1196

1297
export default ScrollAreaThumb;

src/components/ui/ScrollArea/stories/ScrollArea.stories.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@ const ScrollAreaTemplate = (args) => {
99
return (
1010
<SandboxEditor>
1111
<ScrollArea.Root>
12-
<ScrollArea.Viewport className='h-[200px] w-[400px]'>
13-
<div className='bg-gray-100 p-4'>
12+
<ScrollArea.Viewport>
13+
<div className='bg-gray-100 text-gray-950 p-4 max-h-screen'>
1414
<Heading>Scroll Area</Heading>
1515
<Text>This is scrollArea content</Text>
1616

17-
<Heading as='h2'>Scroll Area</Heading>
18-
<Text>
17+
{Array.from({ length: 10 }).map((_, index) => (
18+
<>
19+
<Heading as='h2'>Scroll Area</Heading>
20+
<Text>
1921
Versions of the Lorem ipsum text have been used in typesetting at least since the 1960s, when it was popularized by advertisements for Letraset transfer sheets. It is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin.Versions of the Lorem ipsum text have been used in typesetting at least since the 1960s, when it was popularized by advertisements for Letraset transfer sheets. It is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin.Versions of the Lorem ipsum text have been used in typesetting at least since the 1960s, when it was popularized by advertisements for Letraset transfer sheets. It is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin.
20-
</Text>
22+
</Text>
23+
</>
24+
))}
2125

2226
</div>
2327
</ScrollArea.Viewport>

0 commit comments

Comments
 (0)