Skip to content

Commit 34bc57b

Browse files
committed
feat: support end-anchored virtualizers
1 parent 949180b commit 34bc57b

18 files changed

Lines changed: 1130 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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,42 @@ 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.
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+
### `scrollEndThreshold`
275+
276+
```tsx
277+
scrollEndThreshold?: number
278+
```
279+
280+
**Default:** `1`
281+
282+
The pixel threshold used by `isAtEnd()` and `followOnAppend` to decide whether the viewport is close enough to the end to count as pinned.
283+
248284
### `isScrollingResetDelay`
249285

250286
```tsx
@@ -389,6 +425,34 @@ scrollBy: (
389425
390426
Scrolls the virtualizer by the specified number of pixels relative to the current scroll position.
391427
428+
### `scrollToEnd`
429+
430+
```tsx
431+
scrollToEnd: (
432+
options?: {
433+
behavior?: 'auto' | 'smooth' | 'instant'
434+
}
435+
) => void
436+
```
437+
438+
Scrolls the virtualizer to the end of the content. For vertical lists this is the bottom; for horizontal lists this is the right edge.
439+
440+
### `getDistanceFromEnd`
441+
442+
```tsx
443+
getDistanceFromEnd: () => number
444+
```
445+
446+
Returns the current pixel distance from the end of the virtualized content.
447+
448+
### `isAtEnd`
449+
450+
```tsx
451+
isAtEnd: (threshold?: number) => boolean
452+
```
453+
454+
Returns whether the viewport is within `threshold` pixels of the end. If no threshold is provided, `scrollEndThreshold` is used.
455+
392456
### `getTotalSize`
393457
394458
```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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# TanStack Virtual React Chat Example
2+
3+
```bash
4+
npm install
5+
npm run dev
6+
```

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+
}

examples/react/chat/src/index.css

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
* {
2+
box-sizing: border-box;
3+
}
4+
5+
html {
6+
font-family:
7+
Inter,
8+
ui-sans-serif,
9+
system-ui,
10+
-apple-system,
11+
BlinkMacSystemFont,
12+
'Segoe UI',
13+
sans-serif;
14+
color: #171717;
15+
background: #f6f8fa;
16+
}
17+
18+
body {
19+
margin: 0;
20+
}
21+
22+
button {
23+
border: 1px solid #c6d0da;
24+
border-radius: 6px;
25+
background: #ffffff;
26+
color: #171717;
27+
cursor: pointer;
28+
font: inherit;
29+
font-size: 13px;
30+
padding: 7px 10px;
31+
}
32+
33+
button:hover {
34+
background: #eef3f7;
35+
}
36+
37+
.App {
38+
height: 100vh;
39+
display: grid;
40+
grid-template-rows: auto 1fr;
41+
}
42+
43+
.Toolbar {
44+
align-items: center;
45+
background: #ffffff;
46+
border-bottom: 1px solid #d9e0e6;
47+
display: flex;
48+
gap: 8px;
49+
justify-content: space-between;
50+
padding: 10px 12px;
51+
}
52+
53+
.ToolbarGroup {
54+
display: flex;
55+
gap: 8px;
56+
}
57+
58+
.Status {
59+
color: #5c6670;
60+
font-size: 13px;
61+
}
62+
63+
.Shell {
64+
display: grid;
65+
min-height: 0;
66+
overflow: hidden;
67+
place-items: stretch;
68+
}
69+
70+
.Messages {
71+
min-height: 0;
72+
overflow: auto;
73+
width: 100%;
74+
}
75+
76+
.MessageRow {
77+
padding: 6px 12px;
78+
}
79+
80+
.Bubble {
81+
border: 1px solid #d7dee5;
82+
border-radius: 8px;
83+
line-height: 1.45;
84+
max-width: min(720px, 88vw);
85+
padding: 10px 12px;
86+
white-space: pre-wrap;
87+
}
88+
89+
.Bubble-user {
90+
background: #e6f3ff;
91+
margin-left: auto;
92+
}
93+
94+
.Bubble-assistant {
95+
background: #ffffff;
96+
margin-right: auto;
97+
}
98+
99+
.Meta {
100+
color: #637081;
101+
font-size: 12px;
102+
margin-bottom: 4px;
103+
}

0 commit comments

Comments
 (0)