Skip to content

Commit fc992ab

Browse files
feat: support end-anchored virtualizers (#1173)
* feat: support end-anchored virtualizers * fix chat anchoring review nits
1 parent 949180b commit fc992ab

21 files changed

Lines changed: 1348 additions & 22 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/virtual-core': minor
3+
---
4+
5+
Add end-anchored virtualization support for chat, logs, and reverse feeds.
6+
7+
New `anchorTo: 'end'` mode keeps the current visible item stable when older items are prepended, while preserving the existing start-anchored behavior by default. It also keeps an end-pinned viewport pinned when the last item grows during streaming output.
8+
9+
Add `followOnAppend` so new items scroll into view only when the viewport was already at the end, plus `scrollEndThreshold`, `scrollToEnd()`, `getDistanceFromEnd()`, and `isAtEnd()` helpers for chat-style integrations.

docs/api/virtualizer.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,44 @@ Controls when lane assignments are cached in a masonry layout.
245245
- `'estimate'` (default): lane assignments are cached immediately based on `estimateSize`. This keeps items from jumping between lanes, but assignments may be suboptimal when the estimate is inaccurate.
246246
- `'measured'`: lane caching is deferred until items are measured via `measureElement`, so assignments reflect actual measured sizes. After the initial measurement, lanes are cached and remain stable.
247247

248+
### `anchorTo`
249+
250+
```tsx
251+
anchorTo?: 'start' | 'end'
252+
```
253+
254+
**Default:** `'start'`
255+
256+
Controls which side of the scrollable content should be treated as the stable anchor when list data changes. The default `'start'` preserves TanStack Virtual's existing top/left anchored behavior.
257+
258+
Set `anchorTo: 'end'` for chat, logs, and reverse/inverted feeds. In end-anchored mode, the virtualizer keeps the current visible item stable when older items are prepended, and keeps an end-pinned viewport pinned when the last item grows during streaming output. See the [Chat guide](../chat) for the full pattern.
259+
260+
For prepend stability, use a stable `getItemKey` based on each item's persistent id. Index keys cannot distinguish prepends from appends after items shift.
261+
262+
### `followOnAppend`
263+
264+
```tsx
265+
followOnAppend?: boolean | 'auto' | 'smooth' | 'instant'
266+
```
267+
268+
**Default:** `false`
269+
270+
When used with `anchorTo: 'end'`, controls whether the virtualizer scrolls to the end after new items are appended. The follow only happens if the viewport was already at the end before the append; users who have scrolled up to read history are not pulled down.
271+
272+
Passing `true` is equivalent to `'auto'`. Passing a scroll behavior uses that behavior for the follow.
273+
274+
This option does not follow prepends. It only follows appended output, and only when the viewport was already within `scrollEndThreshold` of the end before the append.
275+
276+
### `scrollEndThreshold`
277+
278+
```tsx
279+
scrollEndThreshold?: number
280+
```
281+
282+
**Default:** `1`
283+
284+
The pixel threshold used by `isAtEnd()` and `followOnAppend` to decide whether the viewport is close enough to the end to count as pinned.
285+
248286
### `isScrollingResetDelay`
249287

250288
```tsx
@@ -389,6 +427,40 @@ scrollBy: (
389427
390428
Scrolls the virtualizer by the specified number of pixels relative to the current scroll position.
391429
430+
### `scrollToEnd`
431+
432+
```tsx
433+
scrollToEnd: (
434+
options?: {
435+
behavior?: 'auto' | 'smooth' | 'instant'
436+
}
437+
) => void
438+
```
439+
440+
Scrolls the virtualizer to the end of the content. For vertical lists this is the bottom; for horizontal lists this is the right edge.
441+
442+
This is useful for "Jump to latest" controls in chat and log views.
443+
444+
### `getDistanceFromEnd`
445+
446+
```tsx
447+
getDistanceFromEnd: () => number
448+
```
449+
450+
Returns the current pixel distance from the end of the virtualized content.
451+
452+
For a vertical list, this is the distance from the bottom.
453+
454+
### `isAtEnd`
455+
456+
```tsx
457+
isAtEnd: (threshold?: number) => boolean
458+
```
459+
460+
Returns whether the viewport is within `threshold` pixels of the end. If no threshold is provided, `scrollEndThreshold` is used.
461+
462+
Use this to decide whether to show "Jump to latest" UI or whether incoming output should be treated as pinned.
463+
392464
### `getTotalSize`
393465
394466
```tsx

docs/chat.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
---
2+
title: Chat
3+
---
4+
5+
Chat, AI streams, logs, and other reverse feeds have a different scrolling contract than a standard top-anchored list. New output usually appears at the end, older history is prepended at the start, and the viewport should only follow new output when the user is already reading the latest item.
6+
7+
TanStack Virtual supports this with end anchoring:
8+
9+
```tsx
10+
const virtualizer = useVirtualizer({
11+
count: messages.length,
12+
getScrollElement: () => parentRef.current,
13+
estimateSize: () => 72,
14+
getItemKey: (index) => messages[index]!.id,
15+
anchorTo: 'end',
16+
followOnAppend: true,
17+
scrollEndThreshold: 80,
18+
overscan: 6,
19+
})
20+
```
21+
22+
See the full [React chat example](framework/react/examples/chat).
23+
24+
## Behaviors
25+
26+
### Start at the latest message
27+
28+
Use `scrollToEnd()` once the scroll element is mounted.
29+
30+
```tsx
31+
React.useLayoutEffect(() => {
32+
virtualizer.scrollToEnd()
33+
}, [virtualizer])
34+
```
35+
36+
For server-rendered or restored screens, you can also use `initialOffset` and `initialMeasurementsCache`, but most chat screens start by imperatively scrolling to the latest item after mount.
37+
38+
### Keep older-history prepends stable
39+
40+
When the user scrolls near the top, load older messages and prepend them to the array. With `anchorTo: 'end'`, TanStack Virtual captures the visible item before the data changes, finds the same keyed item after the prepend, and adjusts the scroll offset so the message stays in the same visual position.
41+
42+
```tsx
43+
setMessages((current) => [...olderMessages, ...current])
44+
```
45+
46+
Stable keys are required for this to work:
47+
48+
```tsx
49+
getItemKey: (index) => messages[index]!.id
50+
```
51+
52+
Do not use index keys for chat history. After a prepend, every existing message shifts to a new index, so index keys cannot identify the same message across the update.
53+
54+
### Follow appended output only when pinned
55+
56+
Set `followOnAppend` to keep the viewport pinned to the end when a new message arrives and the user was already at the end.
57+
58+
```tsx
59+
followOnAppend: true
60+
```
61+
62+
If the user has scrolled up to read history, appended messages do not pull them away. `scrollEndThreshold` controls how close to the end counts as pinned.
63+
64+
```tsx
65+
scrollEndThreshold: 80
66+
```
67+
68+
Use a scroll behavior when you want the follow to animate:
69+
70+
```tsx
71+
followOnAppend: 'smooth'
72+
```
73+
74+
### Keep streaming output pinned
75+
76+
Streaming chat responses usually grow the last item many times. In end-anchored mode, if the viewport is pinned to the end before the measured size changes, the virtualizer adjusts by the size delta and keeps the bottom stuck to the latest output.
77+
78+
This works with the normal dynamic measurement pattern:
79+
80+
```tsx
81+
{virtualizer.getVirtualItems().map((virtualItem) => (
82+
<div
83+
key={virtualItem.key}
84+
ref={virtualizer.measureElement}
85+
data-index={virtualItem.index}
86+
style={{
87+
position: 'absolute',
88+
transform: `translateY(${virtualItem.start}px)`,
89+
width: '100%',
90+
}}
91+
>
92+
<Message message={messages[virtualItem.index]!} />
93+
</div>
94+
))}
95+
```
96+
97+
## Recommended Pattern
98+
99+
Use a normal scroll container and normal item order. You do not need `flex-direction: column-reverse`, inverted transforms, or manual `scrollTop += delta` prepend compensation.
100+
101+
```tsx
102+
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
103+
<div
104+
style={{
105+
height: virtualizer.getTotalSize(),
106+
position: 'relative',
107+
width: '100%',
108+
}}
109+
>
110+
{virtualizer.getVirtualItems().map((virtualItem) => (
111+
<div
112+
key={virtualItem.key}
113+
ref={virtualizer.measureElement}
114+
data-index={virtualItem.index}
115+
style={{
116+
position: 'absolute',
117+
transform: `translateY(${virtualItem.start}px)`,
118+
width: '100%',
119+
}}
120+
>
121+
<Message message={messages[virtualItem.index]!} />
122+
</div>
123+
))}
124+
</div>
125+
</div>
126+
```
127+
128+
## Production Checklist
129+
130+
- Use stable message ids with `getItemKey`.
131+
- Give the scroll element a fixed height and `overflow: auto`.
132+
- Call `measureElement` for dynamic message heights.
133+
- Use `anchorTo: 'end'` for prepend stability and streaming bottom growth.
134+
- Use `followOnAppend` when new output should follow only from the latest position.
135+
- Use `isAtEnd()` to show "Jump to latest" UI when the user is reading history.
136+
- Keep network loading state outside the virtualizer; prepend or append data normally.
137+
138+
## API Reference
139+
140+
- [`anchorTo`](api/virtualizer#anchorto)
141+
- [`followOnAppend`](api/virtualizer#followonappend)
142+
- [`scrollEndThreshold`](api/virtualizer#scrollendthreshold)
143+
- [`scrollToEnd`](api/virtualizer#scrolltoend)
144+
- [`getDistanceFromEnd`](api/virtualizer#getdistancefromend)
145+
- [`isAtEnd`](api/virtualizer#isatend)

docs/config.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
}
5858
]
5959
},
60+
{
61+
"label": "Guides",
62+
"children": [{ "label": "Chat", "to": "chat" }]
63+
},
6064
{
6165
"label": "Core APIs",
6266
"children": [
@@ -136,6 +140,10 @@
136140
"to": "framework/react/examples/infinite-scroll",
137141
"label": "Infinite Scroll"
138142
},
143+
{
144+
"to": "framework/react/examples/chat",
145+
"label": "Chat"
146+
},
139147
{
140148
"to": "framework/react/examples/smooth-scroll",
141149
"label": "Smooth Scroll"

docs/introduction.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ TanStack Virtual is a headless UI utility for virtualizing long lists of element
88

99
At the heart of TanStack Virtual is the `Virtualizer`. Virtualizers can be oriented on either the vertical (default) or horizontal axes which makes it possible to achieve vertical, horizontal and even grid-like virtualization by combining the two axis configurations together.
1010

11+
For chat, AI streams, logs, and other reverse feeds, see the [Chat guide](chat).
12+
1113
Here is just a quick example of what it looks like to virtualize a long list within a div using TanStack Virtual in React:
1214

1315
```tsx

examples/react/chat/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
dist

examples/react/chat/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# TanStack Virtual React Chat Example
2+
3+
Demonstrates end-anchored virtualization for chat-style UIs:
4+
5+
- starts at the latest message
6+
- keeps the visible message stable when older history is prepended
7+
- follows appended output only when the viewport is already at the latest message
8+
- keeps streaming bottom output pinned as the last row grows
9+
10+
```bash
11+
npm install
12+
npm run dev
13+
```

examples/react/chat/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>TanStack Virtual Chat Example</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

examples/react/chat/package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "tanstack-react-virtual-example-chat",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite",
7+
"build": "tsc && vite build",
8+
"serve": "vite preview"
9+
},
10+
"dependencies": {
11+
"@tanstack/react-virtual": "^3.13.25",
12+
"react": "^18.3.1",
13+
"react-dom": "^18.3.1"
14+
},
15+
"devDependencies": {
16+
"@types/react": "^18.3.23",
17+
"@types/react-dom": "^18.3.7",
18+
"@vitejs/plugin-react": "^4.5.2",
19+
"typescript": "5.6.3",
20+
"vite": "^6.4.2"
21+
}
22+
}

0 commit comments

Comments
 (0)