Skip to content

Repository files navigation

react-chat-virtualizer

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.

Install

npm install react-chat-virtualizer

Quick start

import { 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.

Basic chat 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.

Loading older and newer messages

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.

Socket messages and unread state

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.

AI streaming

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} />}
/>

Dynamic images and async content

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>}
/>

Jump to a message

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.

Restore a conversation position

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.

Sticky headers and custom rows

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)}
/>

Headless rendering

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.

External scroll container

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.

SSR and Next.js

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.

React StrictMode

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.

Accessibility and focus

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).

Performance recommendations

  • Memoize expensive message components and keep getItemKey stable.
  • Give media an aspect-ratio estimate when it is known.
  • Tune overscan in pixels for the cost of your rows; { top: 500, bottom: 700 } is a sensible chat starting point.
  • Use gap instead 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.

Debugging

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.

Troubleshooting

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.

Examples and development

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 quality

Architecture and acceptance invariants are documented in docs/architecture.md. Publishing instructions are in docs/publishing.md.

Browser support and known limits

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.

License

MIT

About

A chat-first React virtualizer with dynamic-height anchoring and bottom following.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages