Skip to content
Merged
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
118 changes: 118 additions & 0 deletions src/blog/tanstack-virtual-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
title: Chat UIs Are Lists Until They Aren't
published: 2026-05-25
excerpt: Chat, AI streams, and logs don't behave like ordinary lists. TanStack Virtual now supports end-anchored virtualization for prepend-stable history, append-follow, and streaming output that stays pinned.
library: virtual
authors:
- Tanner Linsley
---

In the last TanStack Virtual release, I left one thing on the table: reverse infinite scroll for chat, and it deserved its own pass.

Chat used to be a niche UI, now it's everywhere, in support inboxes, activity logs, multiplayer feeds, copilots, AI agents, and streaming assistants. They all look like lists, but they don't behave like the lists virtualization libraries were originally built around.

A normal virtual list is start-anchored, so the top of the content is the stable point. You scroll down, append more rows, measure dynamic heights, and everything mostly works.

Chat flips that contract.

- New output appears at the end.
- Older history loads by prepending items at the start.
- The last message can grow token by token while the model is streaming.
- The user should only follow new output if they were already at the latest message.

That last part matters. If someone scrolls up to read history, incoming messages shouldn't yank them back to the bottom, and if they're already there, the UI should stay pinned without every app rewriting the same scroll math.

TanStack Virtual now has a first-class way to model that.

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

## End anchoring

`anchorTo: 'end'` tells the virtualizer that the end of the list is the edge you want to preserve.

When you prepend older messages, TanStack Virtual captures the currently visible item, finds the same keyed item after the data changes, and adjusts the scroll offset so it stays in the same visual position.

That means no `column-reverse`, no inverted transforms, and no manual `scrollTop += delta` bookkeeping in every app. Just normal data:

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

The only real requirement is a stable key:

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

Index keys can't make prepend stability work, because after a prepend every old item moves to a new index, and the virtualizer needs to know which message is still the same message.

## Follow only when pinned

`followOnAppend` handles the "stay at latest, unless I am reading history" rule.

If the user is already near the end, appended messages keep the viewport pinned, and if they've scrolled up, new output lands below without stealing their place.

```tsx
followOnAppend: true
```

You can also pass a scroll behavior:

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

The threshold is configurable too:

```tsx
scrollEndThreshold: 80
```

That same end-state logic is exposed for UI:

```tsx
virtualizer.isAtEnd()
virtualizer.getDistanceFromEnd()
virtualizer.scrollToEnd()
```

So your "Jump to latest" button can use the same rules as the virtualizer itself.

## Streaming output

The modern version of chat isn't append-a-message, it's append a message and then resize it dozens or hundreds of times while tokens stream in.

Without end anchoring, the scroll height grows but the scroll offset doesn't, so the user slowly drifts away from the bottom.

With `anchorTo: 'end'`, if the viewport was pinned before the last item grew, TanStack Virtual applies the size delta and keeps the end pinned.

That's the point of this feature: the common chat behaviors aren't userland chores anymore.

## Still headless

This still isn't a chat component.

TanStack Virtual still doesn't render bubbles, loaders, timestamps, avatars, unread dividers, or composer UI. That part belongs to your app.

What it does now is handle the scroll physics that almost every chat UI needs:

- stable prepends
- conditional append-follow
- pinned streaming growth
- end-distance helpers

It's a small API with a pretty big ergonomic win.

There is also a new [Chat guide](/virtual/latest/docs/chat) and a [React chat example](/virtual/latest/docs/framework/react/examples/chat) showing history prepends, appended messages, streaming replies, and a "Latest" control built with `scrollToEnd()`.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the Chat guide and React chat example files exist

# Search for files that would map to the Chat guide URL
echo "Searching for Chat guide documentation..."
fd -t f -e md -e mdx "chat" --exec echo {} | rg -i "docs.*chat"

# Search for the React chat example
echo -e "\nSearching for React chat example..."
fd -t f -e md -e mdx -e tsx -e ts "chat" --exec echo {} | rg -i "examples.*chat"

# Also check if there's a docs directory structure
echo -e "\nChecking docs directory structure..."
fd -t d "docs" | head -20

Repository: TanStack/tanstack.com

Length of output: 228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Search for the exact link strings in the repo..."
rg -n "/virtual/latest/docs/chat" -S . || true
rg -n "/virtual/latest/docs/framework/react/examples/chat" -S . || true

echo -e "\n2) Locate likely corresponding markdown/mdx files (chat.*)..."
fd -t f \( -iname 'chat.md' -o -iname 'chat.mdx' -o -iname '*chat*.md' -o -iname '*chat*.mdx' \) -S . | head -50

echo -e "\n3) Look for virtual docs content directories..."
fd -t d -S . -i "virtual" | head -50

echo -e "\n4) Search for route/content mapping for /virtual/latest/docs/*..."
rg -n "virtual/latest/docs" -S . | head -50

Repository: TanStack/tanstack.com

Length of output: 1017


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Exact link string references (should be only the blog post if present elsewhere):"
rg -n "/virtual/latest/docs/chat" -S . || true
rg -n "/virtual/latest/docs/framework/react/examples/chat" -S . || true

echo -e "\n2) Find any occurrences of 'virtual/latest/docs' in the repo (route/content references):"
rg -n "virtual/latest/docs" -S . | head -50 || true

echo -e "\n3) Find any occurrences of 'virtual/latest' (docs versioning/slug mapping):"
rg -n "virtual/latest" -S src docs . | head -50 || true

echo -e "\n4) Search docs content for 'chat' within the docs tree:"
fd -t f -i "*chat*" docs | head -50 || true

echo -e "\n5) Search under docs/virtual for 'chat' specifically:"
fd -t f -i "*chat*" docs/virtual docs/virtual/* 2>/dev/null | head -50 || true

echo -e "\n6) Search for likely docs files named chat.md/mdx anywhere under docs/:"
fd -t f -i -g '*chat*.md' -g '*chat*.mdx' docs 2>/dev/null | head -50 || true

echo -e "\n7) Look for docs routing code handling '/virtual/latest/docs/*':"
rg -n "virtual/.+latest.+docs|/virtual/.+docs|latest/docs/\\[|docs/\\[|slug\\]" -S src | head -100 || true

Repository: TanStack/tanstack.com

Length of output: 8947


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "A) Top-level docs entries:"
ls -la docs 2>/dev/null | head -50 || true

echo -e "\nB) Docs dirs containing 'virtual' (glob, not regex):"
fd -t d -g '*virtual*' docs 2>/dev/null | head -50 || true

echo -e "\nC) Docs files containing 'chat' in filename (glob):"
fd -t f -g '*chat*' docs 2>/dev/null | head -100 || true

echo -e "\nD) Search for docs content slugs that include 'chat' (text search):"
rg -n "chat" docs -S --glob '*.{md,mdx,tsx,ts,js,jsx}' 2>/dev/null | head -50 || true

echo -e "\nE) Find docs routing definitions (generic slug handling) in src/routes:"
rg -n "docs/\\[|docs/:|/docs/\\:|\\[\\.\\.\\.|\\[\\.\\.\\.|pkgSlug|virtual/latest/docs|virtual/:|/virtual/" src/routes -S | head -100 || true

echo -e "\nF) Search docs route patterns in src (broader):"
rg -n "virtual.*docs|docs.*slug|docs/\\[\\.{3}|docs/\\[\\w+\\]|pkgSlug" src -S | head -100 || true

Repository: TanStack/tanstack.com

Length of output: 1991


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "A) Top-level directories:"
ls -la | head -100

echo -e "\nB) Any directories with 'virtual' in the name (case-insensitive):"
fd -t d -i '*virtual*' . | head -100

echo -e "\nC) Any markdown/mdx files with 'chat' in the filename (case-insensitive), repo-wide:"
fd -t f -i -g '*chat*' -e md -e mdx . 2>/dev/null | head -100

echo -e "\nD) Search repo-wide for the slug '/latest/docs/chat' (fixed string):"
rg -nF "/latest/docs/chat" -S . | head -50 || true

echo -e "\nE) Search repo-wide for '/docs/chat' (fixed string):"
rg -nF "/docs/chat" -S . | head -50 || true

echo -e "\nF) Search repo-wide for the second slug fragment 'framework/react/examples/chat' (fixed string):"
rg -nF "framework/react/examples/chat" -S . | head -50 || true

echo -e "\nG) Inspect sitemap code around docs path generation:"
sed -n '70,140p' src/utils/sitemap.ts 2>/dev/null || true

echo -e "\nH) Search for how docs slugs are loaded (look for 'latest/docs' usage in src):"
rg -nF "latest/docs/" src 2>/dev/null | head -100 || true

Repository: TanStack/tanstack.com

Length of output: 2923


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Fixed-string search for the target slugs in repo:"
rg -nF "/latest/docs/chat" -S src docs content public . || true
rg -nF "/virtual/latest/docs/chat" -S . || true
rg -nF "framework/react/examples/chat" -S src docs content public . || true

echo -e "\n2) Find any files with 'chat' in the name (glob-based) under likely content dirs:"
fd -t f -g '*chat*' -a src docs content public 2>/dev/null | head -100 || true

echo -e "\n3) Search for 'virtual' docs content sources in code (not just links):"
rg -nF "/${library.id}/latest/docs/" -S src/utils/sitemap.ts src -S || true
rg -nF "latest/docs" -S src | head -100 || true
rg -nF "virtual/latest/docs" -S src | head -100 || true

echo -e "\n4) Inspect sitemap docs generation code around the docs path: "
sed -n '70,130p' src/utils/sitemap.ts 2>/dev/null || true

Repository: TanStack/tanstack.com

Length of output: 2083


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "A) src top-level entries:"
ls -la src | head -100

echo -e "\nB) Check content-collections.ts (docs content source definitions):"
sed -n '1,220p' content-collections.ts

echo -e "\nC) Inspect sitemap docs generation around docs path:"
sed -n '70,160p' src/utils/sitemap.ts

echo -e "\nD) Search for docs route patterns (generic docs slug handling) in src:"
rg -nF "latest/docs/" src | head -100 || true
rg -nF "/:library.id/latest/docs/" src | head -100 || true
rg -nF "/${" src/utils/sitemap.ts src | head -50 || true

echo -e "\nE) Search for 'virtual' in docs/content-related code:"
rg -nF "virtual" content-collections.ts src/utils src | head -100 || true

echo -e "\nF) Find docs route files that likely map docs slugs:"
fd -t f -g '*docs*' src/routes src | head -100 || true

Repository: TanStack/tanstack.com

Length of output: 14106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Find getDocsManifest implementation..."
rg -n "getDocsManifest" src | head -50

echo -e "\n2) Inspect getDocsManifest implementation (show nearby code)..."
# Try likely filename(s)
fd -t f -g '*manifest*' src | head -50
rg -n "async function getDocsManifest|function getDocsManifest" src -S || true

echo -e "\n3) Locate the 'virtual' library entry in src/libraries/libraries.ts..."
rg -n "id:\\s*'virtual'|\\bid:\\s*\"virtual\"|library\\.id\\s*===\\s*'virtual'|virtual\\b" src/libraries/libraries.ts | head -200

echo -e "\n4) Inspect surrounding fields for the virtual entry..."
# If we find a line number from previous grep, we can re-run with sed; otherwise just dump a reasonable chunk.
sed -n '1,120p' src/libraries/libraries.ts
sed -n '180,320p' src/libraries/libraries.ts
sed -n '640,820p' src/libraries/libraries.ts

echo -e "\n5) Search for any cached docs manifests in repo (chat slug might appear there)..."
fd -t f -g '*docs*manifest*' . | head -50
rg -n "docsManifest|paths\\s*:\\s*\\[|/virtual/latest/docs/chat|\"chat\"" -S scripts src . | head -100

Repository: TanStack/tanstack.com

Length of output: 19936


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "A) Inspect virtual library entry (src/libraries/libraries.ts around id 'virtual')..."
sed -n '380,480p' src/libraries/libraries.ts

echo -e "\nB) Inspect getDocsManifest implementation (src/utils/docs.ts)..."
sed -n '1,260p' src/utils/docs.ts

echo -e "\nC) Inspect docs.functions.ts (how manifest is fetched/constructed)..."
sed -n '1,320p' src/utils/docs.functions.ts

echo -e "\nD) Search cached MCP eval results for chat docs slugs..."
rg -nF "/virtual/latest/docs/chat" scripts/mcp-eval/results.json 2>/dev/null || true
rg -nF "virtual/latest/docs/chat" scripts/mcp-eval/results.json 2>/dev/null || true
rg -nF "/virtual/latest/docs/framework/react/examples/chat" scripts/mcp-eval/results.json 2>/dev/null || true
rg -nF "virtual/latest/docs/framework/react/examples/chat" scripts/mcp-eval/results.json 2>/dev/null || true

echo -e "\nE) Broader search in results.json for 'latest/docs/chat' and 'examples/chat' substrings..."
rg -nF "latest/docs/chat" scripts/mcp-eval/results.json 2>/dev/null | head -50 || true
rg -nF "examples/chat" scripts/mcp-eval/results.json 2>/dev/null | head -50 || true

Repository: TanStack/tanstack.com

Length of output: 13549


🌐 Web query:

https://tanstack.com/virtual/latest/docs/chat

💡 Result:

I can’t access a page titled https://tanstack.com/virtual/latest/docs/chat via the search results I’m able to retrieve, so I can’t directly quote or summarize that specific “chat” doc. What I can confirm from the official TanStack Virtual docs is that TanStack Virtual is a headless virtualization utility (not a prebuilt chat component) and that you use it to build a chat UI yourself by virtualizing a long, scrollable list of messages. TanStack Virtual is explicitly described as “Headless UI for Virtualizing Large Element Lists” and it does not ship markup/styles; you retain “100% control” over markup and styling [1][2]. Core pieces you’ll typically apply for a chat UI 1) Use a Virtualizer around a scroll container - The docs explain that the “Virtualizer” is at the heart of TanStack Virtual, and that it can virtualize vertically by default, horizontally, or by combining axes for grids [2]. - The Virtualizer API is available directly; adapters like React’s useVirtualizer configure it for a given scroll element [3][4]. 2) If you need reverse scroll / prepend older messages, manage scroll anchoring yourself - Because TanStack Virtual is headless (not a chat UI), reverse/infinite-chat behavior is implemented by wiring your own scroll math/measurements and reacting to prepends. - TanStack community discussions show patterns for preserving scroll position when items are added to the beginning, including using shouldAdjustScrollPositionOnItemSizeChange and scrollToIndex/scrollToOffset approaches [5][6]. 3) Virtualize measured/dynamic message heights - The docs and API surface support measuring dynamic sizes, which matters for chat where message heights vary [1][2][4]. Practical takeaway - Treat the “/docs/chat” page as non-essential: the reliable official docs say TanStack Virtual is headless, so any “chat” implementation is a composition of the Virtualizer + your chat rendering + your desired scroll behavior (including reverse scrolling and scroll anchoring) [1][2][3][4][6].

Citations:


🌐 Web query:

https://tanstack.com/virtual/latest/docs/framework/react/examples/chat

💡 Result:

The URL you provided (TanStack Virtual → React → examples → chat) appears to not be accessible/doesn’t surface as a distinct “chat” example page in the current TanStack Virtual docs index. However, the TanStack Virtual (React) documentation does provide building blocks and chat-adjacent patterns you can use to implement a chat UI with virtualization. What TanStack Virtual provides (authoritative): 1) TanStack Virtual is a headless virtualization library, with “virtualize only the visible DOM nodes” for large scrollable lists and “100% control over markup and styles” [1]. 2) In React, the standard pattern is to create a virtualizer via useVirtualizer and map the returned virtual items to your rendered UI [1]. Example shape shown in docs: - Use useVirtualizer({ count, getScrollElement, estimateSize }) and then render virtual rows (virtualRow) into your component [1]. Chat-like virtualization patterns from official TanStack resources: A) Infinite scroll (commonly used for chat history loading): - The TanStack Virtual “infinite scroll” React example combines a virtualizer with a loader-row pattern and triggers fetching when the user reaches the end/bottom sentinel [2]. B) Fixed/variable sizing & measurement (important for chat bubbles): - TanStack Virtual supports fixed sizing and (in React examples elsewhere) measured/dynamic row height patterns via virtualizer configuration/measurement hooks [3]. C) “Reversed” chat feeds (new items at the bottom vs. prepending history at the top): - TanStack Virtual maintainers discuss handling reverse virtual lists and scroll offset preservation when prepending variable-height items, including using scroll adjustment logic and virtualizer scrolling utilities like scrollToOffset [4]. Implementation sketch for a virtualized chat list (based on the official APIs/patterns): 1) Create a scroll container (a ref) for the chat viewport. 2) Use useVirtualizer with: - count = number of messages (+ optional loader row) - getScrollElement = => chatContainerRef.current - estimateSize = => estimated message height 3) Render: - A spacer element sized to virtualizer.getTotalSize - Absolutely/relatively positioned message rows using virtualRow.start (or equivalent positioning) for only the virtual items returned by virtualizer.getVirtualItems [1]. 4) For “load older messages” (prepend on scroll-to-top): - Use the infinite-scroll example’s sentinel/loader triggering concept [2], but reversed for “top-of-list loading”. - Preserve scroll position when prepending by applying the scroll offset adjustment strategies discussed in the TanStack Virtual discussion [4]. If you want, paste your current chat component (or describe whether you render “bottom-up” and whether message heights are fixed or variable). I can translate the official infinite-scroll + virtualizer mapping + scroll-preservation guidance into a concrete React code example tailored to your UI.

Citations:


Fix the TanStack Virtual “chat” documentation links in the blog post

src/blog/tanstack-virtual-chat.md:124 links to:

  • /virtual/latest/docs/chat (not reachable/present in the current TanStack Virtual docs)
  • /virtual/latest/docs/framework/react/examples/chat (not reachable/present as a distinct “React chat example” page)

Update these links to the correct existing TanStack Virtual docs pages (or add the missing docs/redirects) so they resolve after publish.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/blog/tanstack-virtual-chat.md` at line 124, Update the two broken links
in src/blog/tanstack-virtual-chat.md: replace the `/virtual/latest/docs/chat`
link and the `/virtual/latest/docs/framework/react/examples/chat` link with the
correct existing TanStack Virtual doc URLs (or add appropriate redirects) so
both resolve after publish; locate the line referencing the "Chat guide" and
"React chat example" and either point them to the current chat docs pages or
create the missing doc/redirects to match those paths.


Chat is one of the dominant UI patterns of modern apps now, and TanStack Virtual should make it feel boring to build.
Loading