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:
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
getItemKey: (index) => messages[index]!.id,
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: 80,
overscan: 6,
})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.
Use scrollToEnd() once the scroll element is mounted.
React.useLayoutEffect(() => {
virtualizer.scrollToEnd()
}, [virtualizer])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.
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.
setMessages((current) => [...olderMessages, ...current])setMessages((current) => [...olderMessages, ...current])Stable keys are required for this to work:
getItemKey: (index) => messages[index]!.idgetItemKey: (index) => messages[index]!.idDo 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.
Set followOnAppend to keep the viewport pinned to the end when a new message arrives and the user was already at the end.
followOnAppend: truefollowOnAppend: trueIf 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.
scrollEndThreshold: 80scrollEndThreshold: 80Use a scroll behavior when you want the follow to animate:
followOnAppend: 'smooth'followOnAppend: 'smooth'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:
{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>
))}{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>
))}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.
<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><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>