@artemtutt/use-chat-virtualizer
v1.0.0
Published
A chat-aware React virtualizer with O(n) tree construction, infinite history, and read receipts.
Maintainers
Readme
@artemtutt/use-chat-virtualizer
A low-level, chat-aware React virtualizer for message lists with dynamic row
heights. It keeps your DOM and styling under application control while handling
the awkward scroll geometry that is specific to chats. It includes built-in
scroll tracking and virtual gap/edge-padding geometry, so no wrapper scroll
handler or CSS spacing workaround is required.
Why
- dynamic heights measured with
ResizeObserver; - one shared observer for the container and all mounted rows;
- automatic passive scroll subscription — no
onScroll={reportScroll}needed; - predictable row-count or pixel-based overscan; pixel mode is suited to dynamically measured messages;
- built-in row gaps and start/end padding, included in virtual offsets and
totalHeight; - prepend history without moving the visible message;
- edge-triggered loading of older history near the start of a chat;
- exact, non-overscanned visible row keys for read receipts;
- bottom pinning only while the user is near the bottom;
- height-change compensation for rows above the viewport;
- jump to a row that is not currently mounted;
- O(n) Fenwick-tree construction, with O(log n) height updates, offset lookups, and mounted-window boundary searches;
- a framework-independent core with batched layout transactions;
- no message schema, markup, or styling imposed by the package.
Tested where chat UIs actually break
Virtualizers can look correct in DOM mocks yet fail when a browser lays out a real message, loads media, or clamps a scroll position. This package combines fast unit tests with browser-level layout tests in Chromium, Firefox, and WebKit.

The browser suite verifies the scroll-sensitive behavior users notice:
- opens a populated chat at the real bottom while mounting only a window;
- preserves a message's measured screen position when history is prepended;
- stays pinned to the bottom as streaming content or delayed media changes a row's height;
- recalculates correctly when the chat viewport is resized;
- responds to native user scrolling, rather than a simulated scroll value.
These assertions compare scrollTop and the anchor row's real
getBoundingClientRect() with a small pixel tolerance. They are geometry
checks, not screenshots alone. Every push and pull request runs the same suite
in CI, with traces, screenshots, and video retained when a test fails.
Install
npm install @artemtutt/use-chat-virtualizerReact 18 or newer is required as a peer dependency.
Run the browser layout suite
Vitest covers the core and React adapter with deterministic DOM mocks. Browser
tests cover real layout, native scrolling, and ResizeObserver behavior in
Chromium, Firefox, and WebKit.
npx playwright install
npm run test:e2eTo run a single engine, append for example -- --project=chromium. Use
npm run test:e2e:headed or npm run test:e2e:ui while debugging.
Compatibility
The published package includes ESM, CommonJS, and TypeScript declarations. It has been verified as a real package consumer with:
- React
18.3.1and React19.2.8; - strict TypeScript compilation with both React 18 and React 19 types;
- React 18 server rendering;
- Next.js
16.3.0App Router and Turbopack production builds; - real browser layout and scroll behavior in Chromium, Firefox, and WebKit.
No Next.js transpilePackages configuration or client-only dynamic import is
required. The React adapter requires a browser with ResizeObserver; the
framework-independent ChatVirtualizerCore does not depend on the DOM.
Basic usage
import { useCallback, useEffect, useRef } from 'react';
import { useChatVirtualizer } from '@artemtutt/use-chat-virtualizer';
function MessageList({ chatId, messages }) {
const scrollElementRef = useRef<HTMLDivElement>(null);
const loadPreviousMessages = useCallback(() => {
// Fetch and prepend the next older page to `messages` in application state.
// The virtualizer preserves the visible-message anchor after the prepend.
}, [chatId]);
const virtualizer = useChatVirtualizer({
chatId,
rows: messages,
scrollElementRef,
getRowKey: (message) => message.id,
estimateRowHeight: (message) => estimateMessageHeight(message),
getMeasurementVersion: (message) => message.layoutVersion,
// Recommended for dynamic-height chat messages.
overscanPx: 600,
gap: 8,
paddingStart: 12,
paddingEnd: 12,
followOutput: 'auto',
onStartReached: loadPreviousMessages,
startReachedThreshold: 300,
});
useEffect(() => {
if (virtualizer.lastVisibleKey) {
markMessagesReadThrough(virtualizer.lastVisibleKey);
}
}, [virtualizer.lastVisibleKey]);
return (
<div
ref={scrollElementRef}
className="scrollContainer"
>
<div
className="virtualTrack"
style={{ height: virtualizer.totalHeight }}
>
{virtualizer.virtualItems.map((item) => (
<div
key={item.key}
ref={virtualizer.getMeasureRef(item.key)}
className="virtualRow"
style={{ top: item.top }}
>
{renderMessage(item.row)}
</div>
))}
</div>
</div>
);
}.scrollContainer {
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior-y: contain;
position: relative;
}
.virtualTrack {
position: relative;
width: 100%;
}
.virtualRow {
left: 0;
position: absolute;
right: 0;
width: 100%;
}The hook subscribes to the scrolling element itself, so no JSX onScroll is
required. reportScroll remains available for manual integrations.
Use gap, paddingStart, and paddingEnd for virtual spacing. gap exists
only between rows; both paddings are included in totalHeight. Do not also add
CSS flex/grid gap or row margins that are not included in the measured row
height, because that would make DOM geometry differ from virtual offsets. A
negative, NaN, or infinite spacing value is normalized to 0.
Choosing overscan
overscan preserves the original row-count behavior: it mounts a fixed number
of extra rows on each side of the viewport.
useChatVirtualizer({
// …required options
overscan: 10,
});For dynamic chat rows, prefer overscanPx. It mounts rows intersecting the
viewport expanded by this many CSS pixels above and below, so a tall image or a
short text message does not make the pre-render distance unpredictable.
useChatVirtualizer({
// …required options
overscanPx: 600,
});If both are present, overscanPx wins — including overscanPx: 0 — and the
values are never combined. Changing overscanPx at runtime updates only the
mounted window; it does not reset scroll anchoring, follow-output behavior, or
the onStartReached edge state.
Next.js App Router
The component that calls useChatVirtualizer must be a Client Component. Add
'use client' before imports in that file. A Server Component can fetch the
messages and pass serializable rows into it.
// app/chat/page.tsx — Server Component
import { Chat } from './chat';
export default async function ChatPage() {
const messages = await loadMessages();
return <Chat chatId="support" messages={messages} />;
}// app/chat/chat.tsx — Client Component
'use client';
import { useRef } from 'react';
import { useChatVirtualizer } from '@artemtutt/use-chat-virtualizer';
type Message = {
id: string;
text: string;
};
export function Chat({
chatId,
messages,
}: {
chatId: string;
messages: Message[];
}) {
const scrollElementRef = useRef<HTMLDivElement>(null);
const virtualizer = useChatVirtualizer({
chatId,
rows: messages,
scrollElementRef,
getRowKey: (message) => message.id,
estimateRowHeight: () => 56,
});
return (
<div
ref={scrollElementRef}
style={{
height: 600,
overflowY: 'auto',
overflowAnchor: 'none',
position: 'relative',
}}
>
<div style={{ height: virtualizer.totalHeight, position: 'relative' }}>
{virtualizer.virtualItems.map((item) => (
<div
key={item.key}
ref={virtualizer.getMeasureRef(item.key)}
style={{
left: 0,
position: 'absolute',
right: 0,
top: item.top,
}}
>
{item.row.text}
</div>
))}
</div>
</div>
);
}The hook is safe to import during server rendering, but it must be called from
a Client Component in the App Router because it uses React state, effects,
event handlers, and browser measurement after hydration. The Pages Router does
not require the 'use client' directive.
API
useChatVirtualizer(options)
Required options:
rows— your flat list of renderable rows;getRowKey— returns a stable, unique string key;estimateRowHeight— returns the initial height before measurement;scrollElementRef— ref of the scrolling element.
Optional options:
chatId— resets measurements, observers, anchors, and initial position when switching conversations;overscan— extra rows mounted on each side, default10. This is the legacy row-count mode and remains unchanged whenoverscanPxis omitted;overscanPx— extra CSS pixels mounted above and below the viewport. This is recommended for dynamic-height chat rows, where a fixed number of rows can represent wildly different distances. When it is provided, including0, it takes precedence overoverscan; the two values are never added together.0, negative,NaN, and infinite values result in no extra pixel window;gap— pixels between adjacent rows; it is never added after the final row;paddingStart/paddingEnd— pixels before the first and after the last row, both included intotalHeight; invalid spacing values become0;atBottomThreshold— bottom proximity in pixels, default96;onStartReached— asks the application to load an older page when a non-empty list enters the start threshold. The library never fetches or mutates messages itself;startReachedThreshold— start proximity in pixels, default300. Negative values become0; non-finite values use the default;initialScroll—bottom(default) ortop;getMeasurementVersion— invalidates a cached height when a row keeps its key but changes layout substantially;followOutput—false,auto,smooth, or a policy function deciding how appended rows should be followed;onAtBottomChange— notification when the derived bottom state changes.
The hook returns:
virtualItemsandtotalHeightfor rendering;getMeasureRef(key)for a stable measurement ref;measureElement(key, node)when manual ref management is preferred;reportScroll()for manual scroll-event integrations (automatic subscription is enabled by default);scrollToKey,scrollToIndex, andscrollToBottom;getOffsetForKey;isAtBottom.visibleRange, whosestartIndexis inclusive andendIndexis exclusive; unlikevirtualItems, this range never includes row or pixel overscan;firstVisibleKeyandlastVisibleKey, ornullif no row intersects the viewport.
Both overscan modes use a half-open expanded viewport: a row whose top is
exactly at its upper boundary is not mounted. Space occupied solely by virtual
gap or edge padding does not produce a row.
scrollToKey works even if the row is outside the mounted window. If the
message has not been loaded yet, loading pages remains the application's
responsibility; call scrollToKey after the target row appears in rows.
The method returns false when the key is not loaded. Estimated jumps are
automatically corrected after the target receives its real measurement.
Following appended output
The default followOutput: 'auto' follows appended rows only when the user was
already near the bottom. smooth uses smooth scrolling under the same rule.
Applications can distinguish their own messages with a policy:
followOutput: ({ wasAtBottom }) =>
sentByCurrentUser ? 'smooth' : wasAtBottom ? 'auto' : falseFor event-specific behavior outside a rows update, call scrollToBottom()
directly after sending.
Loading older messages and read receipts
onStartReached is edge-triggered: it fires once when a non-empty chat enters
the threshold (including an initial top position), then is re-armed only after
the user leaves that zone and returns. It is reset when chatId changes. This
prevents repeated fetch requests from renders, scrolling while already near the
top, or ResizeObserver updates. A list shorter than its viewport is inside
the threshold, so it produces one request per entry/chat; an empty list does
not request anything. The callback runs after React commits and always uses the
latest callback reference, so it is safe with SSR and React Strict Mode.
Use the non-overscanned visible keys for read receipts rather than
virtualItems, whose mounted window may include messages outside the viewport:
const loadPreviousMessages = useCallback(() => {
loadOlderPage(chatId); // update rows in your application when it resolves
}, [chatId]);
const virtualizer = useChatVirtualizer({
chatId,
rows: messages,
getRowKey: (message) => message.id,
estimateRowHeight: estimateMessageHeight,
scrollElementRef,
onStartReached: loadPreviousMessages,
startReachedThreshold: 300,
});
useEffect(() => {
if (virtualizer.lastVisibleKey !== null) {
sendReadReceipt(chatId, virtualizer.lastVisibleKey);
}
}, [chatId, virtualizer.lastVisibleKey]);Prepending history
No imperative prepend call is needed. Before a rows update the hook remembers the first visible row key and its position inside the viewport. After new rows are prepended it restores that key-based anchor. Later measurements of the new rows are compensated independently.
If the visible anchor row is deleted, the core selects the nearest surviving row and preserves its viewport position.
Architecture
ChatVirtualizerCore owns rows, cached measurements, the Fenwick height index,
anchors, and pending jump intent. Its HeightIndex constructs its Fenwick tree
in linear time while retaining logarithmic updates, offset lookups, and boundary
searches. The core has no React or DOM dependency and is exported for custom
adapters. useChatVirtualizer is a thin React/DOM layer that observes the
scroll container and rows with one shared ResizeObserver, subscribes to the
core through useSyncExternalStore, and applies the resulting scroll
adjustments before paint.
Development
npm install
npm run check
npm run bench:height-indexThe package builds ESM, CommonJS, source maps, and TypeScript declarations into
dist/. The deterministic height-index benchmark compares the previous
O(n log n) construction strategy with the current O(n) build for 1,000,
10,000, and 100,000 rows. Use it to compare relative performance on your own
machine; absolute timings naturally vary by environment.
