Skip to content

Commit be89e29

Browse files
authored
fix(virtual-core): smooth scrolling for dynamic item sizes (#1108)
1 parent d2a9995 commit be89e29

File tree

11 files changed

+714
-101
lines changed

11 files changed

+714
-101
lines changed

.changeset/little-wolves-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
fix(virtual-core): smooth scrolling for dynamic item sizes

docs/api/virtualizer.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ A function that returns the scrollable element for the virtualizer. It may retur
3434
estimateSize: (index: number) => number
3535
```
3636

37-
> 🧠 If you are dynamically measuring your elements, it's recommended to estimate the largest possible size (width/height, within comfort) of your items. This will ensure features like smooth-scrolling will have a better chance at working correctly.
37+
> 🧠 If you are dynamically measuring your elements, it's recommended to estimate the largest possible size (width/height, within comfort) of your items. This will help the virtualizer calculate more accurate initial positions.
3838
3939
This function is passed the index of each item and should return the actual size (or estimated size if you will be dynamically measuring items with `virtualItem.measureElement`) for each item. This measurement should return either the width or height depending on the orientation of your virtualizer.
4040

@@ -166,8 +166,6 @@ An optional function that (if provided) should implement the scrolling behavior
166166

167167
Note that built-in scroll implementations are exported as `elementScroll` and `windowScroll`, which are automatically configured by the framework adapter functions like `useVirtualizer` or `useWindowVirtualizer`.
168168

169-
> ⚠️ Attempting to use smoothScroll with dynamically measured elements will not work.
170-
171169
### `observeElementRect`
172170

173171
```tsx
@@ -349,6 +347,23 @@ scrollToIndex: (
349347
350348
Scrolls the virtualizer to the items of the index provided. You can optionally pass an alignment mode to anchor the scroll to a specific part of the scrollElement.
351349
350+
> 🧠 During smooth scrolling, the virtualizer only measures items within a buffer range around the scroll target. Items far from the target are skipped to prevent their size changes from shifting the target position and breaking the smooth animation.
351+
>
352+
> Because of this, the preferred layout strategy for smooth scrolling is **block translation** — translate the entire rendered block using the first item's `start` offset, rather than positioning each item independently with absolute positioning. This ensures items stay correctly positioned relative to each other even when some measurements are skipped.
353+
354+
### `scrollBy`
355+
356+
```tsx
357+
scrollBy: (
358+
delta: number,
359+
options?: {
360+
behavior?: 'auto' | 'smooth'
361+
}
362+
) => void
363+
```
364+
365+
Scrolls the virtualizer by the specified number of pixels relative to the current scroll position.
366+
352367
### `getTotalSize`
353368
354369
```tsx

examples/react/dynamic/src/main.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ function RowVirtualizerDynamic() {
2626
enabled,
2727
})
2828

29+
React.useEffect(() => {
30+
virtualizer.scrollToIndex(count - 1, { align: 'end' })
31+
}, [])
32+
2933
const items = virtualizer.getVirtualItems()
3034

3135
return (
@@ -40,7 +44,7 @@ function RowVirtualizerDynamic() {
4044
<span style={{ padding: '0 4px' }} />
4145
<button
4246
onClick={() => {
43-
virtualizer.scrollToIndex(count / 2)
47+
virtualizer.scrollToIndex(count / 2, { behavior: 'smooth' })
4448
}}
4549
>
4650
scroll to the middle

packages/react-virtual/e2e/app/scroll/main.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ const App = () => {
3636
>
3737
Scroll to 1000
3838
</button>
39+
<button
40+
id="scroll-to-last"
41+
onClick={() => rowVirtualizer.scrollToIndex(1001)}
42+
>
43+
Scroll to last
44+
</button>
45+
<button id="scroll-to-0" onClick={() => rowVirtualizer.scrollToIndex(0)}>
46+
Scroll to 0
47+
</button>
3948

4049
<div
4150
ref={parentRef}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
</head>
6+
<body>
7+
<div id="root"></div>
8+
<script type="module" src="./main.tsx"></script>
9+
</body>
10+
</html>
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React from 'react'
2+
import ReactDOM from 'react-dom/client'
3+
import { useVirtualizer } from '@tanstack/react-virtual'
4+
5+
function getRandomInt(min: number, max: number) {
6+
return Math.floor(Math.random() * (max - min + 1)) + min
7+
}
8+
9+
const randomHeight = (() => {
10+
const cache = new Map<string, number>()
11+
return (id: string) => {
12+
const value = cache.get(id)
13+
if (value !== undefined) {
14+
return value
15+
}
16+
const v = getRandomInt(25, 100)
17+
cache.set(id, v)
18+
return v
19+
}
20+
})()
21+
22+
const App = () => {
23+
const parentRef = React.useRef<HTMLDivElement>(null)
24+
25+
const rowVirtualizer = useVirtualizer({
26+
count: 1002,
27+
getScrollElement: () => parentRef.current,
28+
estimateSize: () => 50,
29+
})
30+
31+
return (
32+
<div>
33+
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
34+
<button
35+
id="scroll-to-100"
36+
onClick={() =>
37+
rowVirtualizer.scrollToIndex(100, { behavior: 'smooth' })
38+
}
39+
>
40+
Smooth scroll to 100
41+
</button>
42+
<button
43+
id="scroll-to-500"
44+
onClick={() =>
45+
rowVirtualizer.scrollToIndex(500, { behavior: 'smooth' })
46+
}
47+
>
48+
Smooth scroll to 500
49+
</button>
50+
<button
51+
id="scroll-to-1000"
52+
onClick={() =>
53+
rowVirtualizer.scrollToIndex(1000, { behavior: 'smooth' })
54+
}
55+
>
56+
Smooth scroll to 1000
57+
</button>
58+
<button
59+
id="scroll-to-0"
60+
onClick={() =>
61+
rowVirtualizer.scrollToIndex(0, { behavior: 'smooth' })
62+
}
63+
>
64+
Smooth scroll to 0
65+
</button>
66+
<button
67+
id="scroll-to-500-start"
68+
onClick={() =>
69+
rowVirtualizer.scrollToIndex(500, {
70+
behavior: 'smooth',
71+
align: 'start',
72+
})
73+
}
74+
>
75+
Smooth scroll to 500 (start)
76+
</button>
77+
<button
78+
id="scroll-to-500-center"
79+
onClick={() =>
80+
rowVirtualizer.scrollToIndex(500, {
81+
behavior: 'smooth',
82+
align: 'center',
83+
})
84+
}
85+
>
86+
Smooth scroll to 500 (center)
87+
</button>
88+
</div>
89+
90+
<div
91+
ref={parentRef}
92+
id="scroll-container"
93+
style={{ height: 400, overflow: 'auto' }}
94+
>
95+
<div
96+
style={{
97+
height: rowVirtualizer.getTotalSize(),
98+
position: 'relative',
99+
}}
100+
>
101+
{rowVirtualizer.getVirtualItems().map((v) => (
102+
<div
103+
key={v.key}
104+
data-testid={`item-${v.index}`}
105+
ref={rowVirtualizer.measureElement}
106+
data-index={v.index}
107+
style={{
108+
position: 'absolute',
109+
top: 0,
110+
left: 0,
111+
transform: `translateY(${v.start}px)`,
112+
width: '100%',
113+
}}
114+
>
115+
<div style={{ height: randomHeight(String(v.key)) }}>
116+
Row {v.index}
117+
</div>
118+
</div>
119+
))}
120+
</div>
121+
</div>
122+
</div>
123+
)
124+
}
125+
126+
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)

packages/react-virtual/e2e/app/test/scroll.spec.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,45 @@ test('scrolls to index 1000', async ({ page }) => {
2828
await expect(page.locator('[data-testid="item-1000"]')).toBeVisible()
2929

3030
const delta = await page.evaluate(check)
31-
console.log('bootom element detla', delta)
3231
expect(delta).toBeLessThan(1.01)
3332
})
33+
34+
test('scrolls to last item', async ({ page }) => {
35+
await page.goto('/scroll/')
36+
await page.click('#scroll-to-last')
37+
38+
await page.waitForTimeout(1000)
39+
40+
// Last item (index 1001) should be visible
41+
await expect(page.locator('[data-testid="item-1001"]')).toBeVisible()
42+
43+
// Container should be scrolled to the very bottom
44+
const atBottom = await page.evaluate(() => {
45+
const container = document.querySelector('#scroll-container')
46+
if (!container) throw new Error('Container not found')
47+
return Math.abs(
48+
container.scrollTop + container.clientHeight - container.scrollHeight,
49+
)
50+
})
51+
expect(atBottom).toBeLessThan(1.01)
52+
})
53+
54+
test('scrolls to index 0', async ({ page }) => {
55+
await page.goto('/scroll/')
56+
57+
// First scroll down
58+
await page.click('#scroll-to-1000')
59+
await page.waitForTimeout(1000)
60+
61+
// Then scroll to first item
62+
await page.click('#scroll-to-0')
63+
await page.waitForTimeout(1000)
64+
65+
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()
66+
67+
const scrollTop = await page.evaluate(() => {
68+
const container = document.querySelector('#scroll-container')
69+
return container?.scrollTop ?? -1
70+
})
71+
expect(scrollTop).toBeLessThan(1.01)
72+
})

0 commit comments

Comments
 (0)