-
-
Notifications
You must be signed in to change notification settings - Fork 347
docs: add TanStack Virtual chat blog post #944
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+118
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()`. | ||
|
|
||
| Chat is one of the dominant UI patterns of modern apps now, and TanStack Virtual should make it feel boring to build. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 228
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 1017
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 8947
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 1991
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 2923
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 2083
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 14106
🏁 Script executed:
Repository: TanStack/tanstack.com
Length of output: 19936
🏁 Script executed:
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:124links 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