Skip to content

Commit fa1a1d5

Browse files
authored
feat: added scrollArea visual types (#1947)
1 parent 1be631a commit fa1a1d5

9 files changed

Lines changed: 249 additions & 86 deletions

File tree

.changeset/clear-clubs-jam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@radui/ui": minor
3+
---
4+
5+
scroll area visual types

src/components/ui/ScrollArea/ScrollArea.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ ScrollArea.Corner = ScrollAreaCorner;
3232
ScrollArea.displayName = 'ScrollArea';
3333

3434
export type { ScrollAreaRootProps } from './fragments/ScrollAreaRoot';
35+
export type { ScrollAreaScrollbarType } from './context/ScrollAreaContext';
3536
export type { ScrollAreaViewportProps } from './fragments/ScrollAreaViewport';
3637
export type { ScrollAreaScrollbarProps } from './fragments/ScrollAreaScrollbar';
3738
export type { ScrollAreaThumbProps } from './fragments/ScrollAreaThumb';

src/components/ui/ScrollArea/context/ScrollAreaContext.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@
22

33
import { createContext, RefObject } from 'react';
44

5+
export type ScrollAreaScrollbarType = 'auto' | 'always' | 'scroll' | 'hover';
6+
57
interface ScrollAreaContextType {
68
rootClass: string;
79
scrollYThumbRef?: RefObject<HTMLDivElement>;
810
scrollXThumbRef?: RefObject<HTMLDivElement>;
911
scrollAreaViewportRef?: RefObject<HTMLDivElement>;
1012
handleScroll?: () => void;
1113
handleScrollbarClick?: (e : { clientX?: any; clientY?: any; orientation: 'vertical' | 'horizontal' }) => void;
12-
type: 'auto' | 'always' | 'scroll' | 'hover';
14+
type: ScrollAreaScrollbarType;
15+
scrollbarVisible: boolean;
16+
overflow: { x: boolean; y: boolean };
1317
rootRef?: RefObject<HTMLDivElement>;
1418
}
1519

1620
export const ScrollAreaContext = createContext<ScrollAreaContextType>({
1721
rootClass: '',
18-
type: 'hover'
22+
type: 'hover',
23+
scrollbarVisible: false,
24+
overflow: { x: false, y: false }
1925
});

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import React, { useEffect, useRef, forwardRef, ElementRef, ComponentPropsWithout
44
import clsx from 'clsx';
55

66
import { useComponentClass } from '~/components/ui/Theme/useComponentClass';
7-
import { ScrollAreaContext } from '../context/ScrollAreaContext';
7+
import { ScrollAreaContext, type ScrollAreaScrollbarType } from '../context/ScrollAreaContext';
8+
import { useScrollbarVisibility } from '../hooks/useScrollbarVisibility';
89

910
const COMPONENT_NAME = 'ScrollArea';
1011

1112
type ScrollAreaRootElement = ElementRef<'div'>;
1213
export type ScrollAreaRootProps = ComponentPropsWithoutRef<'div'> & {
1314
customRootClass?: string;
14-
type?: 'auto' | 'always' | 'scroll' | 'hover';
15+
/** Controls scrollbar and thumb visibility: always, on scroll (1s fade), on hover + scroll, or when overflowing (auto). */
16+
type?: ScrollAreaScrollbarType;
1517
};
1618

1719
const ScrollAreaRoot = forwardRef<ScrollAreaRootElement, ScrollAreaRootProps>(({
@@ -28,6 +30,7 @@ const ScrollAreaRoot = forwardRef<ScrollAreaRootElement, ScrollAreaRootProps>(({
2830
const scrollAreaViewportRef = useRef<HTMLDivElement>(null);
2931

3032
const [overflow, setOverflow] = React.useState({ x: false, y: false });
33+
const scrollbarVisible = useScrollbarVisibility(type, scrollAreaViewportRef, internalRootRef);
3134

3235
const mergedRootRef = (node: HTMLDivElement | null) => {
3336
(internalRootRef as any).current = node;
@@ -217,11 +220,14 @@ const ScrollAreaRoot = forwardRef<ScrollAreaRootElement, ScrollAreaRootProps>(({
217220
handleScroll,
218221
handleScrollbarClick,
219222
type,
223+
scrollbarVisible,
224+
overflow,
220225
rootRef: internalRootRef
221226
}}>
222227
<div
223228
ref={mergedRootRef}
224229
className={clsx(rootClass, className)}
230+
data-scrollbar-type={type}
225231
data-scrollbar-x={String(overflow.x || type === 'always')}
226232
data-scrollbar-y={String(overflow.y || type === 'always')}
227233
{...props}

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

Lines changed: 6 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -10,85 +10,15 @@ export type ScrollAreaScrollbarProps = ComponentPropsWithoutRef<'div'> & {
1010
};
1111

1212
const ScrollAreaScrollbar = forwardRef<ScrollAreaScrollbarElement, ScrollAreaScrollbarProps>(({ children, className = '', orientation = 'vertical', ...props }, ref) => {
13-
const { rootClass, handleScrollbarClick, scrollXThumbRef, scrollYThumbRef, type, scrollAreaViewportRef, rootRef } = useContext(ScrollAreaContext);
13+
const { rootClass, handleScrollbarClick, scrollXThumbRef, scrollYThumbRef, type, scrollbarVisible, overflow } = useContext(ScrollAreaContext);
1414

1515
const intervalRef = useRef<NodeJS.Timeout | null>(null);
1616
const isScrollingRef = useRef(false);
1717
const removeListenersRef = useRef<(() => void) | null>(null);
1818
const [isScrollingState, setIsScrollingState] = React.useState(false);
1919
const mousePositionRef = useRef<number>(0);
2020

21-
const [visible, setVisible] = React.useState(type === 'always');
22-
const [isOverflowing, setIsOverflowing] = React.useState(false);
23-
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
24-
25-
const show = React.useCallback(() => {
26-
if (type === 'scroll' || type === 'hover') {
27-
setVisible(true);
28-
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
29-
hideTimeoutRef.current = setTimeout(() => {
30-
setVisible(false);
31-
}, 1000);
32-
}
33-
}, [type]);
34-
35-
// Handle scroll visibility
36-
React.useEffect(() => {
37-
if (type === 'always' || type === 'auto') return;
38-
const viewport = scrollAreaViewportRef?.current;
39-
if (!viewport) return;
40-
41-
const handleViewportScroll = () => show();
42-
viewport.addEventListener('scroll', handleViewportScroll);
43-
return () => {
44-
viewport.removeEventListener('scroll', handleViewportScroll);
45-
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
46-
};
47-
}, [type, scrollAreaViewportRef, show]);
48-
49-
// Handle hover visibility
50-
React.useEffect(() => {
51-
if (type !== 'hover') return;
52-
const root = rootRef?.current;
53-
if (!root) return;
54-
55-
const handleMouseEnter = () => {
56-
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
57-
setVisible(true);
58-
};
59-
const handleMouseLeave = () => {
60-
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
61-
hideTimeoutRef.current = setTimeout(() => {
62-
setVisible(false);
63-
}, 500);
64-
};
65-
66-
root.addEventListener('mouseenter', handleMouseEnter);
67-
root.addEventListener('mouseleave', handleMouseLeave);
68-
return () => {
69-
root.removeEventListener('mouseenter', handleMouseEnter);
70-
root.removeEventListener('mouseleave', handleMouseLeave);
71-
};
72-
}, [type, rootRef]);
73-
74-
// Overflow detection
75-
React.useEffect(() => {
76-
const viewport = scrollAreaViewportRef?.current;
77-
if (!viewport) return;
78-
79-
const checkOverflow = () => {
80-
const overflowing = orientation === 'vertical'
81-
? viewport.scrollHeight > viewport.clientHeight
82-
: viewport.scrollWidth > viewport.clientWidth;
83-
setIsOverflowing(overflowing);
84-
};
85-
86-
checkOverflow();
87-
const ro = new ResizeObserver(checkOverflow);
88-
ro.observe(viewport);
89-
Array.from(viewport.children).forEach(c => ro.observe(c));
90-
return () => ro.disconnect();
91-
}, [scrollAreaViewportRef, orientation]);
21+
const isOverflowing = orientation === 'vertical' ? overflow.y : overflow.x;
9222

9323
// Determine whether the auto-scroll should continue based on mouse position
9424
const shouldContinueScrolling = React.useCallback((mousePos: number): boolean => {
@@ -177,7 +107,10 @@ const ScrollAreaScrollbar = forwardRef<ScrollAreaScrollbarElement, ScrollAreaScr
177107
};
178108
}, [isScrollingState, stopContinuousScroll]);
179109

180-
const isVisible = type === 'always' || (type === 'auto' && isOverflowing) || (isOverflowing && visible);
110+
const isVisible =
111+
type === 'always'
112+
|| (type === 'auto' && isOverflowing)
113+
|| (isOverflowing && (type === 'scroll' || type === 'hover') && scrollbarVisible);
181114
const shouldKeepInDOM = isOverflowing || type === 'always';
182115

183116
return (

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ export type ScrollAreaThumbProps = ComponentPropsWithoutRef<'div'> & {
1010
};
1111

1212
const ScrollAreaThumb = forwardRef<ScrollAreaThumbElement, ScrollAreaThumbProps>(({ children, className = '', orientation = 'vertical', ...props }, ref) => {
13-
const { rootClass, scrollXThumbRef, scrollYThumbRef, scrollAreaViewportRef } = useContext(ScrollAreaContext);
13+
const { rootClass, scrollXThumbRef, scrollYThumbRef, scrollAreaViewportRef, type, scrollbarVisible, overflow } = useContext(ScrollAreaContext);
14+
const isOverflowing = orientation === 'vertical' ? overflow.y : overflow.x;
15+
const isVisible =
16+
type === 'always'
17+
|| (type === 'auto' && isOverflowing)
18+
|| (isOverflowing && (type === 'scroll' || type === 'hover') && scrollbarVisible);
1419
const isDraggingRef = useRef(false);
1520
const dragStartRef = useRef({ x: 0, y: 0, scrollTop: 0, scrollLeft: 0 });
1621

@@ -94,6 +99,7 @@ const ScrollAreaThumb = forwardRef<ScrollAreaThumbElement, ScrollAreaThumbProps>
9499
<div
95100
ref={setRef}
96101
className={clsx(rootClass && `${rootClass}-thumb`, className)}
102+
data-state={isVisible ? 'visible' : 'hidden'}
97103
onMouseDown={startDrag}
98104
{...props}
99105
>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
4+
5+
export type ScrollAreaScrollbarType = 'auto' | 'always' | 'scroll' | 'hover';
6+
7+
const SCROLLBAR_HIDE_DELAY_MS = 1000;
8+
9+
export function useScrollbarVisibility(
10+
type: ScrollAreaScrollbarType,
11+
viewportRef: RefObject<HTMLDivElement | null | undefined>,
12+
rootRef: RefObject<HTMLDivElement | null | undefined>
13+
) {
14+
const [scrollbarVisible, setScrollbarVisible] = useState(type === 'always');
15+
const isHoveringRef = useRef(false);
16+
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
17+
18+
const clearHideTimeout = useCallback(() => {
19+
if (hideTimeoutRef.current) {
20+
clearTimeout(hideTimeoutRef.current);
21+
hideTimeoutRef.current = null;
22+
}
23+
}, []);
24+
25+
const scheduleHide = useCallback(() => {
26+
clearHideTimeout();
27+
hideTimeoutRef.current = setTimeout(() => {
28+
if (!isHoveringRef.current) {
29+
setScrollbarVisible(false);
30+
}
31+
}, SCROLLBAR_HIDE_DELAY_MS);
32+
}, [clearHideTimeout]);
33+
34+
const showFromScroll = useCallback(() => {
35+
if (type !== 'scroll' && type !== 'hover') return;
36+
37+
setScrollbarVisible(true);
38+
39+
if (type === 'scroll') {
40+
scheduleHide();
41+
return;
42+
}
43+
44+
if (!isHoveringRef.current) {
45+
scheduleHide();
46+
} else {
47+
clearHideTimeout();
48+
}
49+
}, [type, scheduleHide, clearHideTimeout]);
50+
51+
useEffect(() => {
52+
setScrollbarVisible(type === 'always');
53+
clearHideTimeout();
54+
isHoveringRef.current = false;
55+
}, [type, clearHideTimeout]);
56+
57+
useEffect(() => {
58+
if (type === 'always' || type === 'auto') return;
59+
60+
const viewport = viewportRef?.current;
61+
if (!viewport) return;
62+
63+
const handleViewportScroll = () => showFromScroll();
64+
viewport.addEventListener('scroll', handleViewportScroll, { passive: true });
65+
66+
return () => {
67+
viewport.removeEventListener('scroll', handleViewportScroll);
68+
clearHideTimeout();
69+
};
70+
}, [type, viewportRef, showFromScroll, clearHideTimeout]);
71+
72+
useEffect(() => {
73+
if (type !== 'hover') return;
74+
75+
const root = rootRef?.current;
76+
if (!root) return;
77+
78+
const handleMouseEnter = () => {
79+
isHoveringRef.current = true;
80+
clearHideTimeout();
81+
setScrollbarVisible(true);
82+
};
83+
84+
const handleMouseLeave = () => {
85+
isHoveringRef.current = false;
86+
scheduleHide();
87+
};
88+
89+
root.addEventListener('mouseenter', handleMouseEnter);
90+
root.addEventListener('mouseleave', handleMouseLeave);
91+
92+
return () => {
93+
root.removeEventListener('mouseenter', handleMouseEnter);
94+
root.removeEventListener('mouseleave', handleMouseLeave);
95+
clearHideTimeout();
96+
};
97+
}, [type, rootRef, scheduleHide, clearHideTimeout]);
98+
99+
useEffect(() => () => clearHideTimeout(), [clearHideTimeout]);
100+
101+
return scrollbarVisible;
102+
}

src/components/ui/ScrollArea/scroll-area.clarity.scss

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@
2929
padding: 2px;
3030
background: transparent;
3131
z-index: 10;
32-
transition: background 0.15s ease;
32+
opacity: 1;
33+
pointer-events: auto;
34+
transition: opacity 0.2s ease, background 0.15s ease;
35+
36+
&[data-state="hidden"] {
37+
opacity: 0;
38+
pointer-events: none;
39+
background: transparent !important;
40+
}
3341

3442
&[data-orientation="vertical"] {
3543
width: 10px;
@@ -46,7 +54,7 @@
4654
right: 0;
4755
}
4856

49-
&:hover {
57+
&[data-state="visible"]:hover {
5058
background: var(--rad-ui-color-gray-200); // step 3 — interactive hover surface
5159
}
5260

@@ -55,7 +63,12 @@
5563
background: var(--rad-ui-solid-background);
5664
border-radius: var(--rad-ui-radius-full);
5765
position: relative;
58-
transition: background 0.15s ease;
66+
opacity: 1;
67+
transition: opacity 0.2s ease, background 0.15s ease;
68+
69+
&[data-state="hidden"] {
70+
opacity: 0;
71+
}
5972

6073
&::before {
6174
content: "";
@@ -70,7 +83,7 @@
7083
}
7184
}
7285

73-
&:hover .rad-ui-scroll-area-thumb {
86+
&[data-state="visible"]:hover .rad-ui-scroll-area-thumb[data-state="visible"] {
7487
background: var(--rad-ui-solid-hover);
7588
}
7689
}

0 commit comments

Comments
 (0)