Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/little-wolves-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

fix(virtual-core): smooth scrolling for dynamic item sizes
21 changes: 18 additions & 3 deletions docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ A function that returns the scrollable element for the virtualizer. It may retur
estimateSize: (index: number) => number
```

> 🧠 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.
> 🧠 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.

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.

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

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`.

> ⚠️ Attempting to use smoothScroll with dynamically measured elements will not work.

### `observeElementRect`

```tsx
Expand Down Expand Up @@ -349,6 +347,23 @@ scrollToIndex: (

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.

> 🧠 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.
>
> 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.

### `scrollBy`

```tsx
scrollBy: (
delta: number,
options?: {
behavior?: 'auto' | 'smooth'
}
) => void
```

Scrolls the virtualizer by the specified number of pixels relative to the current scroll position.

### `getTotalSize`

```tsx
Expand Down
6 changes: 5 additions & 1 deletion examples/react/dynamic/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ function RowVirtualizerDynamic() {
enabled,
})

React.useEffect(() => {
virtualizer.scrollToIndex(count - 1, { align: 'end' })
}, [])

const items = virtualizer.getVirtualItems()

return (
Expand All @@ -40,7 +44,7 @@ function RowVirtualizerDynamic() {
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
virtualizer.scrollToIndex(count / 2)
virtualizer.scrollToIndex(count / 2, { behavior: 'smooth' })
}}
>
scroll to the middle
Expand Down
9 changes: 9 additions & 0 deletions packages/react-virtual/e2e/app/scroll/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ const App = () => {
>
Scroll to 1000
</button>
<button
id="scroll-to-last"
onClick={() => rowVirtualizer.scrollToIndex(1001)}
>
Scroll to last
</button>
<button id="scroll-to-0" onClick={() => rowVirtualizer.scrollToIndex(0)}>
Scroll to 0
</button>

<div
ref={parentRef}
Expand Down
10 changes: 10 additions & 0 deletions packages/react-virtual/e2e/app/smooth-scroll/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
126 changes: 126 additions & 0 deletions packages/react-virtual/e2e/app/smooth-scroll/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { useVirtualizer } from '@tanstack/react-virtual'

function getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min
}

const randomHeight = (() => {
const cache = new Map<string, number>()
return (id: string) => {
const value = cache.get(id)
if (value !== undefined) {
return value
}
const v = getRandomInt(25, 100)
cache.set(id, v)
return v
}
})()

const App = () => {
const parentRef = React.useRef<HTMLDivElement>(null)

const rowVirtualizer = useVirtualizer({
count: 1002,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
})

return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<button
id="scroll-to-100"
onClick={() =>
rowVirtualizer.scrollToIndex(100, { behavior: 'smooth' })
}
>
Smooth scroll to 100
</button>
<button
id="scroll-to-500"
onClick={() =>
rowVirtualizer.scrollToIndex(500, { behavior: 'smooth' })
}
>
Smooth scroll to 500
</button>
<button
id="scroll-to-1000"
onClick={() =>
rowVirtualizer.scrollToIndex(1000, { behavior: 'smooth' })
}
>
Smooth scroll to 1000
</button>
<button
id="scroll-to-0"
onClick={() =>
rowVirtualizer.scrollToIndex(0, { behavior: 'smooth' })
}
>
Smooth scroll to 0
</button>
<button
id="scroll-to-500-start"
onClick={() =>
rowVirtualizer.scrollToIndex(500, {
behavior: 'smooth',
align: 'start',
})
}
>
Smooth scroll to 500 (start)
</button>
<button
id="scroll-to-500-center"
onClick={() =>
rowVirtualizer.scrollToIndex(500, {
behavior: 'smooth',
align: 'center',
})
}
>
Smooth scroll to 500 (center)
</button>
</div>

<div
ref={parentRef}
id="scroll-container"
style={{ height: 400, overflow: 'auto' }}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((v) => (
<div
key={v.key}
data-testid={`item-${v.index}`}
ref={rowVirtualizer.measureElement}
data-index={v.index}
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${v.start}px)`,
width: '100%',
}}
>
<div style={{ height: randomHeight(String(v.key)) }}>
Row {v.index}
</div>
</div>
))}
</div>
</div>
</div>
)
}

ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
41 changes: 40 additions & 1 deletion packages/react-virtual/e2e/app/test/scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,45 @@ test('scrolls to index 1000', async ({ page }) => {
await expect(page.locator('[data-testid="item-1000"]')).toBeVisible()

const delta = await page.evaluate(check)
console.log('bootom element detla', delta)
expect(delta).toBeLessThan(1.01)
})

test('scrolls to last item', async ({ page }) => {
await page.goto('/scroll/')
await page.click('#scroll-to-last')

await page.waitForTimeout(1000)

// Last item (index 1001) should be visible
await expect(page.locator('[data-testid="item-1001"]')).toBeVisible()

// Container should be scrolled to the very bottom
const atBottom = await page.evaluate(() => {
const container = document.querySelector('#scroll-container')
if (!container) throw new Error('Container not found')
return Math.abs(
container.scrollTop + container.clientHeight - container.scrollHeight,
)
})
expect(atBottom).toBeLessThan(1.01)
})

test('scrolls to index 0', async ({ page }) => {
await page.goto('/scroll/')

// First scroll down
await page.click('#scroll-to-1000')
await page.waitForTimeout(1000)

// Then scroll to first item
await page.click('#scroll-to-0')
await page.waitForTimeout(1000)

await expect(page.locator('[data-testid="item-0"]')).toBeVisible()

const scrollTop = await page.evaluate(() => {
const container = document.querySelector('#scroll-container')
return container?.scrollTop ?? -1
})
expect(scrollTop).toBeLessThan(1.01)
})
Loading
Loading