Skip to content

Commit 1d84749

Browse files
authored
[scroll area] Prevent scroll snapping while dragging the thumb (#5259)
1 parent 293a0f1 commit 1d84749

7 files changed

Lines changed: 275 additions & 3 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
.Container {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 2rem;
5+
padding: 2rem;
6+
}
7+
8+
.Heading {
9+
margin: 0 0 0.5rem;
10+
font-size: 1rem;
11+
font-weight: 600;
12+
}
13+
14+
.ScrollAreaRoot {
15+
width: 400px;
16+
}
17+
18+
.ScrollAreaViewport {
19+
width: 100%;
20+
scroll-snap-type: x mandatory;
21+
}
22+
23+
.NativeScroller {
24+
width: 400px;
25+
overflow-x: auto;
26+
scroll-snap-type: x mandatory;
27+
}
28+
29+
.Row {
30+
display: flex;
31+
}
32+
33+
.Item {
34+
box-sizing: border-box;
35+
flex-shrink: 0;
36+
display: grid;
37+
place-items: center;
38+
width: 200px;
39+
height: 120px;
40+
scroll-snap-align: start;
41+
background: #f5f5f5;
42+
font-size: 2rem;
43+
44+
&:nth-child(even) {
45+
background: #e0e0e0;
46+
}
47+
}
48+
49+
.ScrollAreaScrollbar {
50+
display: flex;
51+
/* Column direction so the thumb's `flex` stretches it across the track's
52+
thickness, leaving its width to `--scroll-area-thumb-width`. */
53+
flex-direction: column;
54+
height: 10px;
55+
background: rgb(0 0 0 / 0.05);
56+
}
57+
58+
.ScrollAreaThumb {
59+
flex: 1;
60+
border-radius: 20px;
61+
background: rgb(0 0 0 / 0.5);
62+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client';
2+
import { ScrollArea } from '@base-ui/react/scroll-area';
3+
import styles from './scroll-area-snap.module.css';
4+
5+
const ITEMS = Array.from({ length: 10 }, (_, index) => index + 1);
6+
7+
function Items() {
8+
return (
9+
<div className={styles.Row}>
10+
{ITEMS.map((item) => (
11+
<div key={item} className={styles.Item}>
12+
{item}
13+
</div>
14+
))}
15+
</div>
16+
);
17+
}
18+
19+
export default function ScrollAreaSnap() {
20+
return (
21+
<div className={styles.Container}>
22+
<p>
23+
Dragging the thumb should track the pointer continuously and only snap to the nearest item
24+
once the pointer is released, matching the native scroller below.
25+
</p>
26+
27+
<div>
28+
<h2 className={styles.Heading}>Scroll area</h2>
29+
<ScrollArea.Root className={styles.ScrollAreaRoot}>
30+
<ScrollArea.Viewport className={styles.ScrollAreaViewport}>
31+
<Items />
32+
</ScrollArea.Viewport>
33+
<ScrollArea.Scrollbar orientation="horizontal" className={styles.ScrollAreaScrollbar}>
34+
<ScrollArea.Thumb className={styles.ScrollAreaThumb} />
35+
</ScrollArea.Scrollbar>
36+
</ScrollArea.Root>
37+
</div>
38+
39+
<div>
40+
<h2 className={styles.Heading}>Native scroller</h2>
41+
<div className={styles.NativeScroller}>
42+
<Items />
43+
</div>
44+
</div>
45+
</div>
46+
);
47+
}

packages/react/src/scroll-area/root/ScrollAreaRoot.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
7575
const startScrollLeftRef = React.useRef(0);
7676
const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical');
7777
const scrollPositionRef = React.useRef(DEFAULT_COORDS);
78+
const savedSnapTypeRef = React.useRef<string | null>(null);
7879

7980
function startScrolling(vertical: boolean) {
8081
const setScrolling = vertical ? setScrollingY : setScrollingX;
@@ -101,6 +102,20 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
101102
}
102103
});
103104

105+
// CSS scroll snap forces every programmatic scroll to land on a snap
106+
// point, making thumb dragging jump between snap points. Native
107+
// scrollbars suppress snapping while dragging, so disable it until the
108+
// pointer is released; restoring the value re-snaps the viewport. The
109+
// save is guarded so a second pointer during an active drag can't
110+
// clobber the saved value with `none`.
111+
const disableViewportSnap = useStableCallback(() => {
112+
const viewportEl = viewportRef.current;
113+
if (viewportEl && savedSnapTypeRef.current === null) {
114+
savedSnapTypeRef.current = viewportEl.style.scrollSnapType;
115+
viewportEl.style.scrollSnapType = 'none';
116+
}
117+
});
118+
104119
const handlePointerDown = useStableCallback((event: React.PointerEvent) => {
105120
if (event.button !== 0) {
106121
return;
@@ -116,9 +131,11 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
116131
| 'vertical'
117132
| 'horizontal';
118133

119-
if (viewportRef.current) {
120-
startScrollTopRef.current = viewportRef.current.scrollTop;
121-
startScrollLeftRef.current = viewportRef.current.scrollLeft;
134+
const viewportEl = viewportRef.current;
135+
if (viewportEl) {
136+
startScrollTopRef.current = viewportEl.scrollTop;
137+
startScrollLeftRef.current = viewportEl.scrollLeft;
138+
disableViewportSnap();
122139
}
123140

124141
const thumb =
@@ -173,6 +190,13 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
173190
const handlePointerUp = useStableCallback((event: React.PointerEvent) => {
174191
thumbDraggingRef.current = false;
175192

193+
if (savedSnapTypeRef.current !== null) {
194+
if (viewportRef.current) {
195+
viewportRef.current.style.scrollSnapType = savedSnapTypeRef.current;
196+
}
197+
savedSnapTypeRef.current = null;
198+
}
199+
176200
const thumb =
177201
currentOrientationRef.current === 'vertical' ? thumbYRef.current : thumbXRef.current;
178202
// `pointercancel` releases capture implicitly, so guard against releasing a
@@ -237,6 +261,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
237261
handlePointerMove,
238262
handlePointerUp,
239263
handleScroll,
264+
disableViewportSnap,
240265
cornerSize,
241266
setCornerSize,
242267
thumbSize,
@@ -269,6 +294,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
269294
handlePointerMove,
270295
handlePointerUp,
271296
handleScroll,
297+
disableViewportSnap,
272298
cornerSize,
273299
thumbSize,
274300
hasMeasuredScrollbar,

packages/react/src/scroll-area/root/ScrollAreaRootContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface ScrollAreaRootContext {
3232
handlePointerMove: (event: React.PointerEvent) => void;
3333
handlePointerUp: (event: React.PointerEvent) => void;
3434
handleScroll: (scrollPosition: Coords) => void;
35+
disableViewportSnap: () => void;
3536
rootId: string | undefined;
3637
hiddenState: HiddenState;
3738
setHiddenState: React.Dispatch<React.SetStateAction<HiddenState>>;

packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,61 @@ describe('<ScrollArea.Scrollbar />', () => {
397397
});
398398
});
399399

400+
// The jump-to-click assignment must run with snapping already disabled, or the
401+
// assigned position quantizes to the nearest snap point and the thumb stays
402+
// offset from the pointer for the whole drag. Requires real layout for the
403+
// track/thumb offset math, so Chromium only.
404+
describe.skipIf(isJSDOM)('scroll snap on track press', () => {
405+
it('does not snap the initial jump-to-click position', async () => {
406+
await render(
407+
<ScrollArea.Root style={{ width: 400, height: 200 }}>
408+
<ScrollArea.Viewport
409+
data-testid="viewport"
410+
style={{ width: '100%', height: '100%', scrollSnapType: 'x mandatory' }}
411+
>
412+
<div style={{ display: 'flex' }}>
413+
{Array.from({ length: 10 }, (_, index) => (
414+
<div
415+
key={index}
416+
style={{ flexShrink: 0, width: 200, height: 100, scrollSnapAlign: 'start' }}
417+
/>
418+
))}
419+
</div>
420+
</ScrollArea.Viewport>
421+
<ScrollArea.Scrollbar orientation="horizontal" data-testid="scrollbar" keepMounted>
422+
<ScrollArea.Thumb data-testid="thumb" />
423+
</ScrollArea.Scrollbar>
424+
</ScrollArea.Root>,
425+
);
426+
427+
const viewport = screen.getByTestId('viewport') as HTMLDivElement;
428+
const scrollbar = screen.getByTestId('scrollbar');
429+
const thumb = screen.getByTestId('thumb');
430+
await waitFor(() => expect(thumb.offsetWidth).toBeGreaterThan(0));
431+
432+
// Aim mid-way between the 800 and 1000 snap points (200px items).
433+
const targetScroll = 900;
434+
const maxScroll = viewport.scrollWidth - viewport.clientWidth;
435+
const maxThumbOffset = scrollbar.offsetWidth - thumb.offsetWidth;
436+
const rect = scrollbar.getBoundingClientRect();
437+
const clickX =
438+
rect.left + (targetScroll / maxScroll) * maxThumbOffset + thumb.offsetWidth / 2;
439+
440+
fireEvent.pointerDown(scrollbar, {
441+
button: 0,
442+
clientX: clickX,
443+
clientY: rect.top + rect.height / 2,
444+
pointerId: 1,
445+
});
446+
447+
expect(Math.abs(viewport.scrollLeft - targetScroll)).toBeLessThanOrEqual(1);
448+
449+
// Releasing restores snapping, which re-snaps to the nearest snap point.
450+
fireEvent.pointerUp(scrollbar, { pointerId: 1 });
451+
await waitFor(() => expect(viewport.scrollLeft % 200).toBe(0));
452+
});
453+
});
454+
400455
// A short or heavily padded track drives `maxThumbOffset` to zero or negative
401456
// once the thumb hits its `MIN_THUMB_SIZE` floor. Dragging the thumb then
402457
// divides by a non-positive offset, teleporting the scroll position to an

packages/react/src/scroll-area/scrollbar/ScrollAreaScrollbar.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar
4343
handlePointerDown,
4444
handlePointerUp,
4545
handleScroll,
46+
disableViewportSnap,
4647
rootId,
4748
thumbSize,
4849
hasMeasuredScrollbar,
@@ -162,6 +163,12 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar
162163
const scrollRatio = clickPosition / maxThumbOffset;
163164
const maxScrollDistance = scrollableSize - viewportSize;
164165

166+
// Disable snapping before the jump-to-click assignment, or the
167+
// assigned position quantizes to the nearest snap point and the thumb
168+
// stays offset from the pointer for the whole drag. `handlePointerDown`
169+
// below re-runs this as a guarded no-op for the thumb-drag path.
170+
disableViewportSnap();
171+
165172
if (vertical) {
166173
viewportEl.scrollTop = scrollRatio * maxScrollDistance;
167174
} else if (direction === 'rtl') {

packages/react/src/scroll-area/thumb/ScrollAreaThumb.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,80 @@ describe('<ScrollArea.Thumb />', () => {
239239
await waitFor(() => expect(scrollbar).not.toHaveAttribute('data-scrolling'));
240240
});
241241

242+
describe('scroll snap', () => {
243+
function defineThumbPointerCapture(thumb: HTMLElement) {
244+
Object.defineProperties(thumb, {
245+
setPointerCapture: {
246+
configurable: true,
247+
value: () => {},
248+
},
249+
hasPointerCapture: {
250+
configurable: true,
251+
value: () => false,
252+
},
253+
});
254+
}
255+
256+
function renderWithSnap() {
257+
return render(
258+
<ScrollArea.Root style={{ width: 200, height: 200 }}>
259+
<ScrollArea.Viewport
260+
data-testid="viewport"
261+
style={{ width: '100%', height: '100%', scrollSnapType: 'y mandatory' }}
262+
>
263+
<div style={{ width: 200, height: 1000 }} />
264+
</ScrollArea.Viewport>
265+
<ScrollArea.Scrollbar orientation="vertical" keepMounted>
266+
<ScrollArea.Thumb data-testid="thumb" />
267+
</ScrollArea.Scrollbar>
268+
</ScrollArea.Root>,
269+
);
270+
}
271+
272+
it('disables viewport scroll snap while dragging and restores it on release', async () => {
273+
await renderWithSnap();
274+
275+
const viewport = screen.getByTestId('viewport');
276+
const thumb = screen.getByTestId('thumb');
277+
defineThumbPointerCapture(thumb);
278+
279+
fireEvent.pointerDown(thumb, { button: 0, clientY: 0, pointerId: 1 });
280+
expect(viewport.style.scrollSnapType).toBe('none');
281+
282+
fireEvent.pointerUp(thumb, { pointerId: 1 });
283+
expect(viewport.style.scrollSnapType).toBe('y mandatory');
284+
});
285+
286+
it('restores viewport scroll snap on pointer cancel', async () => {
287+
await renderWithSnap();
288+
289+
const viewport = screen.getByTestId('viewport');
290+
const thumb = screen.getByTestId('thumb');
291+
defineThumbPointerCapture(thumb);
292+
293+
fireEvent.pointerDown(thumb, { button: 0, clientY: 0, pointerId: 1 });
294+
expect(viewport.style.scrollSnapType).toBe('none');
295+
296+
fireEvent.pointerCancel(thumb, { pointerId: 1 });
297+
expect(viewport.style.scrollSnapType).toBe('y mandatory');
298+
});
299+
300+
it('keeps the saved scroll snap value when a second pointer starts mid-drag', async () => {
301+
await renderWithSnap();
302+
303+
const viewport = screen.getByTestId('viewport');
304+
const thumb = screen.getByTestId('thumb');
305+
defineThumbPointerCapture(thumb);
306+
307+
fireEvent.pointerDown(thumb, { button: 0, clientY: 0, pointerId: 1 });
308+
fireEvent.pointerDown(thumb, { button: 0, clientY: 0, pointerId: 2 });
309+
expect(viewport.style.scrollSnapType).toBe('none');
310+
311+
fireEvent.pointerUp(thumb, { pointerId: 1 });
312+
expect(viewport.style.scrollSnapType).toBe('y mandatory');
313+
});
314+
});
315+
242316
describe('data-scrolling attribute', () => {
243317
const { render: renderWithClock, clock } = createRenderer();
244318

0 commit comments

Comments
 (0)