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
9 changes: 9 additions & 0 deletions .changeset/chat-reverse-virtualization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/virtual-core': minor
---

Add end-anchored virtualization support for chat, logs, and reverse feeds.

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.

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.
72 changes: 72 additions & 0 deletions docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,44 @@ Controls when lane assignments are cached in a masonry layout.
- `'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.
- `'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.

### `anchorTo`

```tsx
anchorTo?: 'start' | 'end'
```

**Default:** `'start'`

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.

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.

For prepend stability, use a stable `getItemKey` based on each item's persistent id. Index keys cannot distinguish prepends from appends after items shift.

### `followOnAppend`

```tsx
followOnAppend?: boolean | 'auto' | 'smooth' | 'instant'
```

**Default:** `false`

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.

Passing `true` is equivalent to `'auto'`. Passing a scroll behavior uses that behavior for the follow.

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.

### `scrollEndThreshold`

```tsx
scrollEndThreshold?: number
```

**Default:** `1`

The pixel threshold used by `isAtEnd()` and `followOnAppend` to decide whether the viewport is close enough to the end to count as pinned.

### `isScrollingResetDelay`

```tsx
Expand Down Expand Up @@ -389,6 +427,40 @@ scrollBy: (

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

### `scrollToEnd`

```tsx
scrollToEnd: (
options?: {
behavior?: 'auto' | 'smooth' | 'instant'
}
) => void
```

Scrolls the virtualizer to the end of the content. For vertical lists this is the bottom; for horizontal lists this is the right edge.

This is useful for "Jump to latest" controls in chat and log views.

### `getDistanceFromEnd`

```tsx
getDistanceFromEnd: () => number
```

Returns the current pixel distance from the end of the virtualized content.

For a vertical list, this is the distance from the bottom.

### `isAtEnd`

```tsx
isAtEnd: (threshold?: number) => boolean
```

Returns whether the viewport is within `threshold` pixels of the end. If no threshold is provided, `scrollEndThreshold` is used.

Use this to decide whether to show "Jump to latest" UI or whether incoming output should be treated as pinned.

### `getTotalSize`

```tsx
Expand Down
145 changes: 145 additions & 0 deletions docs/chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
title: Chat
---

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.

TanStack Virtual supports this with end anchoring:

```tsx
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
getItemKey: (index) => messages[index]!.id,
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: 80,
overscan: 6,
})
```

See the full [React chat example](framework/react/examples/chat).

## Behaviors

### Start at the latest message

Use `scrollToEnd()` once the scroll element is mounted.

```tsx
React.useLayoutEffect(() => {
virtualizer.scrollToEnd()
}, [virtualizer])
```

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.

### Keep older-history prepends stable

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.

```tsx
setMessages((current) => [...olderMessages, ...current])
```

Stable keys are required for this to work:

```tsx
getItemKey: (index) => messages[index]!.id
```

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.

### Follow appended output only when pinned

Set `followOnAppend` to keep the viewport pinned to the end when a new message arrives and the user was already at the end.

```tsx
followOnAppend: true
```

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.

```tsx
scrollEndThreshold: 80
```

Use a scroll behavior when you want the follow to animate:

```tsx
followOnAppend: 'smooth'
```

### Keep streaming output pinned

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.

This works with the normal dynamic measurement pattern:

```tsx
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
}}
>
<Message message={messages[virtualItem.index]!} />
</div>
))}
```

## Recommended Pattern

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.

```tsx
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
width: '100%',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
}}
>
<Message message={messages[virtualItem.index]!} />
</div>
))}
</div>
</div>
```

## Production Checklist

- Use stable message ids with `getItemKey`.
- Give the scroll element a fixed height and `overflow: auto`.
- Call `measureElement` for dynamic message heights.
- Use `anchorTo: 'end'` for prepend stability and streaming bottom growth.
- Use `followOnAppend` when new output should follow only from the latest position.
- Use `isAtEnd()` to show "Jump to latest" UI when the user is reading history.
- Keep network loading state outside the virtualizer; prepend or append data normally.

## API Reference

- [`anchorTo`](api/virtualizer#anchorto)
- [`followOnAppend`](api/virtualizer#followonappend)
- [`scrollEndThreshold`](api/virtualizer#scrollendthreshold)
- [`scrollToEnd`](api/virtualizer#scrolltoend)
- [`getDistanceFromEnd`](api/virtualizer#getdistancefromend)
- [`isAtEnd`](api/virtualizer#isatend)
8 changes: 8 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
}
]
},
{
"label": "Guides",
"children": [{ "label": "Chat", "to": "chat" }]
},
{
"label": "Core APIs",
"children": [
Expand Down Expand Up @@ -136,6 +140,10 @@
"to": "framework/react/examples/infinite-scroll",
"label": "Infinite Scroll"
},
{
"to": "framework/react/examples/chat",
"label": "Chat"
},
{
"to": "framework/react/examples/smooth-scroll",
"label": "Smooth Scroll"
Expand Down
2 changes: 2 additions & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ TanStack Virtual is a headless UI utility for virtualizing long lists of element

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.

For chat, AI streams, logs, and other reverse feeds, see the [Chat guide](chat).

Here is just a quick example of what it looks like to virtualize a long list within a div using TanStack Virtual in React:

```tsx
Expand Down
2 changes: 2 additions & 0 deletions examples/react/chat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
13 changes: 13 additions & 0 deletions examples/react/chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# TanStack Virtual React Chat Example

Demonstrates end-anchored virtualization for chat-style UIs:

- starts at the latest message
- keeps the visible message stable when older history is prepended
- follows appended output only when the viewport is already at the latest message
- keeps streaming bottom output pinned as the last row grows

```bash
npm install
npm run dev
```
12 changes: 12 additions & 0 deletions examples/react/chat/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TanStack Virtual Chat Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
22 changes: 22 additions & 0 deletions examples/react/chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "tanstack-react-virtual-example-chat",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.25",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "5.6.3",
"vite": "^6.4.2"
}
}
Loading
Loading