npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@artemtutt/use-chat-virtualizer

v1.0.0

Published

A chat-aware React virtualizer with O(n) tree construction, infinite history, and read receipts.

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.

Older history is prepended while message #20 keeps its screen position

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-virtualizer

React 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:e2e

To 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.1 and React 19.2.8;
  • strict TypeScript compilation with both React 18 and React 19 types;
  • React 18 server rendering;
  • Next.js 16.3.0 App 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, default 10. This is the legacy row-count mode and remains unchanged when overscanPx is 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, including 0, it takes precedence over overscan; 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 in totalHeight; invalid spacing values become 0;
  • atBottomThreshold — bottom proximity in pixels, default 96;
  • 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, default 300. Negative values become 0; non-finite values use the default;
  • initialScrollbottom (default) or top;
  • getMeasurementVersion — invalidates a cached height when a row keeps its key but changes layout substantially;
  • followOutputfalse, auto, smooth, or a policy function deciding how appended rows should be followed;
  • onAtBottomChange — notification when the derived bottom state changes.

The hook returns:

  • virtualItems and totalHeight for 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, and scrollToBottom;
  • getOffsetForKey;
  • isAtBottom.
  • visibleRange, whose startIndex is inclusive and endIndex is exclusive; unlike virtualItems, this range never includes row or pixel overscan;
  • firstVisibleKey and lastVisibleKey, or null if 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' : false

For 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-index

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