A chat-first React virtualizer for lists whose content can change size at any time. It keeps a stable visible anchor while history is prepended, media loads, Markdown reflows, messages are edited, and streaming responses grow. When the reader is at the bottom, it follows output; when they scroll away, it stays out of the way.
The package is headless and ships no required stylesheet. It targets React 18 and 19, emits ESM with declarations and source maps, and has no runtime dependency other than its React peers.
npm install react-chat-virtualizerimport { ChatVirtualizer } from "react-chat-virtualizer";
export function Conversation({ messages }: { messages: Message[] }) {
return (
<ChatVirtualizer
style={{ height: 640 }}
items={messages}
getItemKey={(message) => message.id}
estimateItemHeight={76}
initialPosition="bottom"
followOutput="auto"
renderItem={(message) => <MessageBubble message={message} />}
/>
);
}Give the internal scroller an explicit height (or a height supplied by its layout). Item wrappers
are measured with ResizeObserver; normal content changes require no reset or measurement call.
Stable, unique keys are required for reliable prepend, delete, and restoration behavior.
initialPosition="bottom" is the default. The list remains hidden until its initial offset is
installed, so it does not flash at the top during the first client commit. shortContentAlign
also defaults to "bottom".
followOutput="auto" follows appended messages only while the reader is bottom-locked.
"always" forces appends into view and "never" never follows them. The default 80-pixel
bottom threshold is configurable.
const list = useRef<ChatVirtualizerHandle>(null);
<ChatVirtualizer
ref={list}
items={messages}
getItemKey={(message) => message.id}
bottomThreshold={96}
onAtBottomChange={setAtBottom}
onUnseenCountChange={setUnseenCount}
renderItem={(message) => <Message message={message} />}
/>
<button onClick={() => list.current?.scrollToBottom({ behavior: "smooth" })}>
{unseenCount} new messages
</button>The smooth-scroll destination is recomputed every animation frame. An image load or streaming token during the animation therefore changes the destination rather than leaving the viewport short of the real bottom.
Pagination callbacks are guarded per direction, receive an AbortSignal, and are aborted during
unmount. Repeated threshold events do not start a duplicate request.
<ChatVirtualizer
items={messages}
getItemKey={(message) => message.id}
hasOlder={hasOlder}
loadingOlder={loadingOlder}
loadOlder={async ({ signal }) => {
const older = await api.history({ before: messages[0]?.id, signal });
setMessages((current) => [...older, ...current]);
}}
loadOlderThreshold={300}
hasNewer={hasNewer}
loadNewer={({ signal }) => loadNewerPage(signal)}
renderItem={(message) => <Message message={message} />}
/>The retained visible key, not the old raw scrollTop, is used after a prepend. Large batches use
estimated heights immediately and converge as their items are eventually measured.
Append socket/SSE/polling results to items normally. If the reader is away from the bottom,
their viewport is preserved and onUnseenCountChange reports the number of prefix-preserving
appends. Calling scrollToBottom clears it. Reorders are intentionally not guessed as unread
messages.
Update the last item through ordinary React state. streamingFollow="auto" keeps it pinned only
while bottom-locked. A wheel, touch, scrollbar, or keyboard scroll away cancels following and any
active smooth scroll. Use "never" if token growth should always preserve visible content, or
"always" together with your own interaction policy when output must remain visible.
<ChatVirtualizer
items={turns}
getItemKey={(turn) => turn.id}
estimateItemHeight={(turn) => (turn.kind === "tool" ? 240 : 96)}
followOutput="auto"
streamingFollow="auto"
renderItem={(turn) => <StreamingMarkdown source={turn.text} />}
/>Render images, Suspense boundaries, code blocks, audio players, and expandable content normally.
The border box of each virtual item is observed and consecutive changes are committed as one
animation-frame transaction. Avoid applying layout-affecting margins to the virtual wrapper;
use gap or padding inside your item instead.
<ChatVirtualizer
gap={8}
items={messages}
getItemKey={(message) => message.id}
estimateItemHeight={(message) => (message.image ? 280 : 72)}
renderItem={(message) => <article>{message.image && <img src={message.image} alt="" />}</article>}
/>Targets do not need to be mounted. They do need to exist in the current item array.
list.current?.scrollToIndex(500, { align: "center" });
list.current?.scrollToKey(messageId, { align: "nearest", behavior: "smooth" });If an application opens around an unloaded search result, fetch the containing page first, commit
it to items, then call scrollToKey.
Snapshots store a stable key and its viewport coordinate rather than a fragile raw scroll value.
const snapshots = new Map<string, ScrollSnapshot>();
snapshots.set(conversationId, list.current!.getScrollSnapshot()!);
// After the saved key is present again:
list.current?.restoreScrollSnapshot(snapshots.get(conversationId)!);Known measurements can also be saved with getMeasurements() and passed back through
initialMeasurements. Mounted content is always remeasured, so a stale cache converges to real
layout.
Items are generic: date separators, unread markers, typing indicators, loaders, and system rows can live in the same collection. A sticky overlay can be derived from the first visible item.
<ChatVirtualizer
items={rows}
getItemKey={(row) => row.key}
renderStickyHeader={({ firstVisibleItem }) => <DatePill date={firstVisibleItem.date} />}
renderItem={(row) => renderRow(row)}
/>useChatVirtualizer exposes virtual items, measurement refs, container/content refs, state, and
the same imperative handle. It is intended for custom markup that still uses an element scroll
container. See the API reference for the complete return type.
Pass an existing HTMLElement through scrollElement. The virtualized content remains in normal
document order inside that element and offsets account for content before it. The optimized and
fully tested path remains the internal element scroller. Window/document scrolling is not exposed
in this release.
The package does not inspect DOM globals during module evaluation. In Next.js, put the component that owns chat state behind a client boundary:
"use client";
import { ChatVirtualizer } from "react-chat-virtualizer";Server and first-client markup use the same estimated range and hidden initial state. Measurement starts after commit, avoiding an SSR hydration mismatch. Use deterministic keys and estimates on both sides.
Observers, listeners, timers, frames, and request controllers are installed and cleaned in commit
effects. Pagination is deferred through a cancellable task so StrictMode's development effect
cycle does not duplicate a network callback. Do not trigger network work from renderItem.
The component defaults to role="log", is keyboard-scrollable, and keeps a focused virtual item
mounted if it leaves the overscan window. Supply an accessible label and put semantics on your
message markup. For very large history, expose search/jump controls rather than implying that a
screen reader can traverse unmounted DOM. Announce newly received messages through an application
live region; the scroller itself uses aria-live="off" to avoid replaying rows during recycling.
Vertical math is direction-independent and works under dir="rtl". The playground includes
responsive mobile sizing and touch momentum (-webkit-overflow-scrolling: touch).
- Memoize expensive message components and keep
getItemKeystable. - Give media an aspect-ratio estimate when it is known.
- Tune
overscanin pixels for the cost of your rows;{ top: 500, bottom: 700 }is a sensible chat starting point. - Use
gapinstead of external item margins. - Do not recreate every item object for a single streaming update when that can be avoided.
The size engine uses a double-precision Fenwick tree: offset lookup and measurement updates are
O(log n), while an item-array reconciliation is O(n). Run npm run benchmark for local
100,000-item numbers. The latest reference run is recorded in
docs/benchmarks.md. Browser DOM count and anchor invariants are separately
covered by Playwright.
Pass debug to show a lightweight development overlay, or pass a DebugOptions object and
subscribe to onDebug for anchor, range, correction, size, and scroll data. Debug output is not
rendered in production builds.
The viewport has no height. Set a height/min-height through layout or style. A virtualizer
cannot infer how much of a zero-height scroller to render.
A prepend moves content. Confirm that old messages keep the same unique keys. Index keys turn every prepend into an identity replacement.
Rows overlap briefly. Do not use transforms or display: contents on the generated item
wrapper through itemStyle. Put visual transforms on a child.
A CSS animation visibly moves content. Layout animations intentionally expose intermediate frames. The final geometry is corrected, but perfect anchoring during an intentional height transition is not guaranteed. Prefer transform/opacity animations or disable them while reading history.
Mobile keyboard behavior differs on a device. See the browser compatibility checklist; emulation cannot reproduce the iOS visual viewport and momentum implementation exactly.
The playground contains basic-chat, infinite-history, ai-streaming, media-chat,
realtime-chat, live-resize, restore-position, and huge-chat scenarios. The live-resize
scenario keeps message 10 centered while only messages 1–5 and 20–25 alternate heights every
second. The changing rows are highlighted and updates can be paused from the toolbar.
npm install
npm run dev
npm test
npm run test:stress
npx playwright install
npm run test:e2e
npm run qualityArchitecture and acceptance invariants are documented in docs/architecture.md. Publishing instructions are in docs/publishing.md.
Automated suites target Chromium, Firefox, WebKit, desktop, and a mobile Chrome viewport. On-device iOS Safari momentum, URL-bar transitions, and virtual-keyboard interruption require the documented manual pass. Native scroll anchoring is disabled inside the managed scroller so it cannot compete with key-based corrections. Nested element scrollers work when the intended element is supplied; window scrolling and pixel-perfect CSS layout-animation frames are outside the current contract.
MIT