Skip to content

Commit 133a2b9

Browse files
atrakhConvex, Inc.
authored andcommitted
dashboard: make TableScrollbar look nicer and scroll smoothly (#36655)
Smoother, more performant animations for the scrollbar and makes it match modern macOS scrollbar styles GitOrigin-RevId: 00798d1e32bf9d1b629f3dff90c8c14a388947d3
1 parent e90767c commit 133a2b9

File tree

3 files changed

+96
-31
lines changed

3 files changed

+96
-31
lines changed

npm-packages/@convex-dev/design-system/src/styles/shared.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ html.light {
8484
--content-link: var(--blue-700);
8585
--border-transparent: 33, 34, 30, 0.14;
8686
--border-selected: 30, 28, 25;
87+
--macos-scrollbar-thumb: 193, 193, 193;
88+
--macos-scrollbar-thumb-hover: 125, 125, 125;
89+
--macos-scrollbar-track: 250, 250, 250;
8790
}
8891

8992
html.dark {
@@ -107,6 +110,9 @@ html.dark {
107110
--content-link: var(--blue-200);
108111
--border-transparent: 163, 156, 148, 0.3;
109112
--border-selected: 225, 215, 205;
113+
--macos-scrollbar-thumb: 107, 107, 107;
114+
--macos-scrollbar-thumb-hover: 147, 147, 147;
115+
--macos-scrollbar-track: 43, 43, 43;
110116
}
111117

112118
@layer base {

npm-packages/@convex-dev/design-system/src/tailwind.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ export const numberedColors = {
6464
},
6565
};
6666

67+
const macosScrollbarColors = {
68+
macosScrollbar: {
69+
thumb: `rgba(var(--macos-scrollbar-thumb), <alpha-value>)`,
70+
thumbHover: `rgba(var(--macos-scrollbar-thumb-hover), <alpha-value>)`,
71+
track: `rgba(var(--macos-scrollbar-track), <alpha-value>)`,
72+
},
73+
};
74+
6775
type ThemeColors = {
6876
background: {
6977
brand: string;
@@ -266,6 +274,7 @@ const config: Config = {
266274
util: utilColors,
267275
...themeColors,
268276
...numberedColors,
277+
...macosScrollbarColors,
269278
},
270279
fontFamily: {
271280
display: [

npm-packages/dashboard-common/src/features/data/components/Table/TableScrollbar.tsx

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { RefObject, useCallback, useEffect, useState } from "react";
1+
import { RefObject, useCallback, useEffect, useState, useRef } from "react";
22
import { useWindowSize } from "react-use";
33
import { FixedSizeList } from "react-window";
44
import { useTableDensity } from "@common/features/data/lib/useTableDensity";
55

6-
const MIN_SCROLLBAR_SIZE = 12;
6+
const MIN_SCROLLBAR_SIZE = 64;
77

88
// The handlers returned by this hook were mostly copied/inspired by:
99
// https://www.thisdot.co/blog/creating-custom-scrollbars-with-react
@@ -31,6 +31,8 @@ function useScrollbar(
3131
);
3232
const [initialScrollTop, setInitialScrollTop] = useState<number>(0);
3333
const [isDragging, setIsDragging] = useState(false);
34+
const rafIdRef = useRef<number>(0);
35+
const lastMouseYRef = useRef<number | null>(null);
3436

3537
const handleTrackClick = useCallback(
3638
(e: React.MouseEvent) => {
@@ -68,39 +70,75 @@ function useScrollbar(
6870
const handleThumbMouseup = useCallback(() => {
6971
if (isDragging) {
7072
setIsDragging(false);
73+
74+
// Cancel any pending animation frame
75+
if (rafIdRef.current) {
76+
cancelAnimationFrame(rafIdRef.current);
77+
rafIdRef.current = 0;
78+
}
7179
}
7280
}, [isDragging]);
7381

82+
const updateScrollPosition = useCallback(() => {
83+
if (
84+
isDragging &&
85+
outerEl &&
86+
listEl &&
87+
scrollStartPosition &&
88+
lastMouseYRef.current !== null
89+
) {
90+
const {
91+
scrollHeight: contentScrollHeight,
92+
offsetHeight: contentOffsetHeight,
93+
} = outerEl;
94+
95+
// Subtract the current mouse y position from where you started to get the pixel difference
96+
const deltaY =
97+
(lastMouseYRef.current - scrollStartPosition) *
98+
(contentOffsetHeight / scrollbarHeight);
99+
100+
const newScrollTop = Math.max(
101+
0,
102+
Math.min(
103+
initialScrollTop + deltaY,
104+
contentScrollHeight - contentOffsetHeight,
105+
),
106+
);
107+
108+
// Apply the scroll
109+
listEl?.scrollTo(newScrollTop);
110+
111+
// Continue animation loop
112+
rafIdRef.current = requestAnimationFrame(updateScrollPosition);
113+
}
114+
}, [
115+
isDragging,
116+
scrollStartPosition,
117+
scrollbarHeight,
118+
outerEl,
119+
listEl,
120+
initialScrollTop,
121+
]);
122+
74123
const handleThumbMousemove = useCallback(
75124
(e: MouseEvent) => {
76-
if (isDragging && outerEl && listEl && scrollStartPosition) {
125+
if (isDragging) {
77126
e.preventDefault();
78127
e.stopPropagation();
79-
const {
80-
scrollHeight: contentScrollHeight,
81-
offsetHeight: contentOffsetHeight,
82-
} = outerEl;
83-
84-
// Subtract the current mouse y position from where you started to get the pixel difference in mouse position. Multiply by ratio of visible content height to thumb height to scale up the difference for content scrolling.
85-
const deltaY =
86-
(e.clientY - scrollStartPosition) *
87-
(contentOffsetHeight / scrollbarHeight);
88-
const newScrollTop = Math.min(
89-
initialScrollTop + deltaY,
90-
contentScrollHeight - contentOffsetHeight,
91-
);
92128

93-
listEl?.scrollTo(newScrollTop);
129+
// Store mouse position for animation frame
130+
lastMouseYRef.current = e.clientY;
131+
132+
// Start animation frame if not already running
133+
if (!rafIdRef.current) {
134+
rafIdRef.current = requestAnimationFrame(updateScrollPosition);
135+
}
136+
137+
// Add user-select: none to body during drag
138+
document.body.style.userSelect = "none";
94139
}
95140
},
96-
[
97-
isDragging,
98-
scrollStartPosition,
99-
scrollbarHeight,
100-
outerEl,
101-
listEl,
102-
initialScrollTop,
103-
],
141+
[isDragging, updateScrollPosition],
104142
);
105143

106144
// Listen for mouse events to handle scrolling by dragging the thumb
@@ -112,6 +150,14 @@ function useScrollbar(
112150
document.removeEventListener("mousemove", handleThumbMousemove);
113151
document.removeEventListener("mouseup", handleThumbMouseup);
114152
document.removeEventListener("mouseleave", handleThumbMouseup);
153+
154+
// Reset user-select
155+
document.body.style.userSelect = "";
156+
157+
// Clean up any ongoing animation frame
158+
if (rafIdRef.current) {
159+
cancelAnimationFrame(rafIdRef.current);
160+
}
115161
};
116162
}, [handleThumbMousemove, handleThumbMouseup]);
117163

@@ -126,11 +172,13 @@ function useScrollbar(
126172
(outerEl.scrollTop / totalRowHeight) * outerEl.offsetHeight,
127173
0,
128174
),
129-
outerEl.offsetHeight - scrollbarHeight,
175+
// Subtract an extra pixel to prevent scrollbar from going too far down
176+
outerEl.offsetHeight - scrollbarHeight - 6,
130177
)
131178
: 0,
132179
handleTrackClick,
133180
handleThumbMousedown,
181+
isDragging,
134182
};
135183
}
136184

@@ -148,6 +196,7 @@ export function TableScrollbar({
148196
scrollbarTop,
149197
handleTrackClick,
150198
handleThumbMousedown,
199+
isDragging,
151200
} = useScrollbar(totalRowCount || 0, outerRef, listRef);
152201

153202
// Create a React handler from a native event handler, just for the types.
@@ -157,9 +206,9 @@ export function TableScrollbar({
157206
);
158207

159208
const { densityValues } = useTableDensity();
160-
return scrollbarHeight >= 0 ? (
209+
return scrollbarHeight > 0 ? (
161210
<div
162-
className="absolute right-0 w-2"
211+
className="absolute -right-px -mt-0.5 w-3 border-l border-t bg-macosScrollbar-track/75 py-0.5"
163212
role="scrollbar"
164213
aria-controls="dataTable"
165214
aria-valuenow={scrollbarTop}
@@ -171,7 +220,7 @@ export function TableScrollbar({
171220
{/* eslint-disable */}
172221
{/* I have no clue how to properly do a11y for this scrollbar,
173222
but it seems to work well for scrollbars */}
174-
<div onClick={handleTrackClick} className="fixed h-full w-2" />
223+
<div onClick={handleTrackClick} className="fixed h-full w-2.5" />
175224
<div
176225
style={{
177226
height:
@@ -185,11 +234,12 @@ export function TableScrollbar({
185234
scrollbarHeight < MIN_SCROLLBAR_SIZE
186235
? // If the scrollbar is using the minimum size, add some margin
187236
// to the top so it snaps to the top and bottom of the table.
188-
Math.max(scrollbarTop - (MIN_SCROLLBAR_SIZE + 1), 0)
237+
Math.max(scrollbarTop - MIN_SCROLLBAR_SIZE, 0)
189238
: scrollbarTop,
239+
cursor: isDragging ? "grabbing" : "grab",
190240
}}
191241
onMouseDown={handleReactThumbMousedown}
192-
className={`fixed w-2 bg-neutral-1 dark:bg-neutral-8`}
242+
className="fixed w-1.5 rounded-full transition-colors bg-macosScrollbar-thumb hover:bg-macosScrollbar-thumbHover ml-0.5"
193243
/>
194244
{/* eslint-enable */}
195245
</div>

0 commit comments

Comments
 (0)