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

@aiquants/virtualscroll

v3.7.0

Published

High-performance virtual scrolling component for React with variable item heights

Readme

@aiquants/virtualscroll

High-performance virtual scrolling component for React with variable item heights using Fenwick Tree optimization.

Features

  • ⚡ High Performance: Optimized for thousands of items with O(log n) operations
  • 📐 Variable Heights: Support for items with different heights
  • 🎯 Precise Scrolling: Accurate scroll positioning and smooth navigation
  • 📱 Touch Support: Full support for touch devices
  • ↔️ Horizontal Delegation: Opt-in onWheelHorizontal hands horizontal wheel / trackpad (and shift+wheel) gestures to the parent — build frozen-column data grids
  • 🎨 Customizable: Flexible styling and theming options
  • 🌀 Ultrafast Tap Scroll: Adaptive tap scroll circle that scales speed up to 120× for massive datasets
  • ♿ Accessibility opt-ins: Escape row-return (enableEscapeRowReturn) and a screen-reader live region (liveRegion) announcing the visible range with your own wording
  • 🌐 Localization: The built-in chrome strings (scrollbar arrow labels, scroll-to-edge pills, empty state) ship in English (default) and Japanese via locale, with per-key labels overrides. See Localization
  • 🔧 TypeScript: Full TypeScript support with comprehensive type definitions

Installation

npm install @aiquants/virtualscroll
# or
yarn add @aiquants/virtualscroll
# or
pnpm add @aiquants/virtualscroll

Note: the virtualscroll demo CLI is for source checkouts only — the demo app is not bundled in the published npm package. To run it, clone the repository and run pnpm install && pnpm dev inside demo/.

Basic Usage

Required: import the package stylesheet once in your app — the components rely on the .aqvs-* layout classes it ships, and without it the list overflows its viewport and the scrollbar is mislaid. This stylesheet is also a peer CSS dependency of @aiquants/directory-tree, @aiquants/select-box, and @aiquants/daily-report; when you also use those packages, import virtualscroll's CSS just once and they all share it.

The package ships two CSS artifacts. Neither bundles a CSS reset — the standalone build declares @layer base but leaves it empty — so every .aqvs-* rule cancels the UA defaults its own box model depends on: box-sizing, border-style, the UA button bevel and padding, and the tap highlight (2.2.0). The components' box model is therefore identical with and without Tailwind preflight; text that inherits host typography (the empty-state label and the scroll-to-edge pill labels — localized chrome, see Localization — and your own row content) still follows the host's font and default line-height by design. The package's JSX writes .aqvs-* class names only, never a Tailwind utility, and the standalone build ships no utilities either — the .aqvs-* rules are byte-identical in both artifacts, so import exactly one:

/* Tailwind v4 host — components-only build (Artifact A) in the components layer */
@import '@aiquants/virtualscroll/styles/virtualscroll.css' layer(components);
/* Non-Tailwind host — single self-contained standalone build (Artifact B) */
@import '@aiquants/virtualscroll/styles/virtualscroll.standalone.css';
// Load the stylesheet once, e.g. in your app entry point (see the styling note above)
import '@aiquants/virtualscroll/styles/virtualscroll.standalone.css'
import { VirtualScroll } from '@aiquants/virtualscroll'
import { useCallback } from 'react'

type Item = {
  id: number
  text: string
  height: number
}

const items: Item[] = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  text: `Item ${i}`,
  height: Math.floor(Math.random() * 50) + 30, // Random height between 30-80px
}))

function App() {
  const getItem = useCallback((index: number) => items[index], [])
  const getItemHeight = useCallback((index: number) => items[index].height, [])

  return (
    <div style={{ height: '400px', width: '100%', border: '1px solid #d1d5db' }}>
      <VirtualScroll
        itemCount={items.length}
        getItem={getItem}
        getItemHeight={getItemHeight}
        overscanCount={5}
      >
        {(item, index) => (
          <div
            key={item.id}
            style={{
              height: item.height,
              padding: '8px',
              borderBottom: '1px solid #eee',
              display: 'flex',
              alignItems: 'center',
            }}
          >
            <span>#{index}: {item.text}</span>
          </div>
        )}
      </VirtualScroll>
    </div>
  )
}

Sizing

viewportSize is optional and best omitted. When it is absent the component measures its own band (the content element, via ResizeObserver) and every derived value follows automatically.

// The host owns the height; VirtualScroll fills it.
<div className="min-h-0 flex-1">
    <VirtualScroll itemCount={n} getItem={getItem} getItemHeight={getItemHeight}>{renderRow}</VirtualScroll>
</div>

The host must establish a definite height (h-full, flex-1 + min-h-0, an explicit px height, position: absolute, …). With an auto-height host the band stays 0 and the package logs a warning — there is nothing to measure. In self-measured mode the root carries data-self-measured="true" and the packaged rule .aqvs-scroll-pane[data-self-measured="true"] { height: 100% } stretches it to the host, so the stylesheet import is required for this mode as well. Since v3.6.0 the self-measured pane also skips the scrollbar's inline main-axis size (ScrollBar.stretchMainSize) so a flex: 1 host without min-height: 0 can shrink again — the bar's fixed height used to become the host's min-content floor and lock shrinking permanently (one-way ratchet).

Pass viewportSize only when the band is known by calculation, not measurement — a dropdown whose height comes from row count × row height, for example. If you pass a value that disagrees with the real band by more than 1px, the package warns: the clip window, the max scroll position, the thumb mapping, the rendered row count and scrollability all derive from it, so all five would be silently wrong (rows spilling over the next element, a dead band at the bottom, an end you can never reach, a thumb that leaves the viewport, or a list that ignores the wheel entirely).

Horizontal Scrolling

VirtualScroll virtualizes and scrolls the vertical axis only. To add a horizontal axis — e.g. a data grid whose columns extend past the viewport — pass onWheelHorizontal. Horizontal-dominant wheel / trackpad gestures (and shift+wheel) are then delegated to your handler, which drives a horizontal offset you own. Vertical scrolling is untouched. When onWheelHorizontal is omitted, horizontal gestures and shift+wheel bypass vertical scroll interception so parent native scrolling (e.g., overflow-x: auto) works seamlessly without being consumed.

A common pattern freezes a left column and translates the remaining columns via a shared CSS variable (so rows don't re-render on horizontal scroll), reusing the package's ScrollBar with the horizontal prop for the bottom bar:

import { ScrollBar, VirtualScroll } from '@aiquants/virtualscroll'
import { useCallback, useState } from 'react'

const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))

function Grid() {
  const [hscroll, setHscroll] = useState(0)
  const maxHScroll = /* contentWidth - visibleWidth */ 632
  const onWheelHorizontal = useCallback((dx: number) => setHscroll((h) => clamp(h + dx, maxHScroll)), [maxHScroll])

  // `--hx` on the container is inherited by every row; numeric tracks translate via CSS only.
  return (
    <div style={{ ['--hx' as string]: `${hscroll}px`, display: 'flex', flexDirection: 'column' }}>
      <div style={{ height: 440 }}>
        <VirtualScroll itemCount={rows.length} getItem={getItem} getItemHeight={getItemHeight} onWheelHorizontal={onWheelHorizontal}>
          {(row) => (
            <div style={{ display: 'flex' }}>
              <div style={{ width: 180, flexShrink: 0 }}>{row.label}{/* frozen column */}</div>
              <div style={{ flex: 1, overflow: 'hidden' }}>
                <div style={{ transform: 'translateX(calc(-1 * var(--hx)))' }}>{/* numeric columns */}</div>
              </div>
            </div>
          )}
        </VirtualScroll>
      </div>
      <ScrollBar
        horizontal
        contentSize={1200}
        viewportSize={568}
        scrollPosition={hscroll}
        onScroll={(next) => { const c = clamp(typeof next === 'function' ? next(hscroll) : next, maxHScroll); setHscroll(c); return c }}
      />
    </div>
  )
}

A runnable version lives in the demo at /horizontal (pnpm demo:dev), and @aiquants/directory-tree's TreeGrid mode uses this exact pattern in production.

Bridging input from outside the pane

The wheel listener lives on .aqvs-scroll-pane only, so anything you render outside it — a frozen column header, a sticky footer, the horizontal scrollbar row, an empty-state panel — is a wheel dead zone. Data tables pin the header outside the virtual list by convention, so this gap is the norm, not an edge case.

Use useWheelBridge: it gives that element the pane's own wheel semantics.

import { useRef } from 'react'
import { useWheelBridge, VirtualScroll, type VirtualScrollHandle } from '@aiquants/virtualscroll'

const listRef = useRef<VirtualScrollHandle>(null)
const headerRef = useWheelBridge(listRef)

<div ref={headerRef}>frozen header — outside the pane</div>
<VirtualScroll ref={listRef} onWheelHorizontal={setScrollX} ...>{renderRow}</VirtualScroll>

The hook takes no configuration that affects wheel semantics, and that is the point. Wheel speed, the horizontal sink, whether the list can scroll, inertia and axis resolution all live behind the handle, in one function (applyWheel) that the pane's own listener goes through too. Give the bridge its own settings and the same grid ends up scrolling at one speed over the rows and another over the header — we measured exactly that — 30px vs 10px for one wheel tick at wheelSpeedMultiplier: 3 — in a work-in-progress build that did give the bridge its own settings. With a single home for the rules, that mismatch is not expressible. The hook's job is { passive: false } registration, teardown, and the enableBridge on/off switch — nothing else. Deciding whether an event was already applied belongs to applyWheel, which tracks the events this package consumed (see below).

Its one option is enableBridge (default true) — a plain on/off switch, not a semantic knob. Pass { enableBridge: false } to stop bridging without unmounting the element:

const headerRef = useWheelBridge(listRef, { enableBridge: !isEditing })

It works with ScrollPane directly too: the parameter is typed against WheelBridgeTarget ({ applyWheel }), which both VirtualScrollHandle and ScrollPaneHandle satisfy.

Composing with your own ref is safe — the hook keeps a per-element ledger and skips re-attaching to an element it already holds, so the common inline merge cannot pile up listeners (without it, four re-renders leave five live wheel listeners on one element). Return the hook's value so React 19 can run its cleanup on unmount:

<div ref={(node) => { myRef.current = node; return headerRef(node) }} />

Dropping that return still works — the ledger keeps it correct — but the listener then stays on the element until the element itself is garbage-collected.

The ledger is keyed per element, so one hook can serve several bands (a header and a footer) without them fighting over a single slot.

Wheel scrolling chains to the page at the edges (like a native scroll container). A vertical wheel that cannot move the list any further in its own direction is left to the ancestors, so a user wheeling down a long page passes through the list instead of getting trapped. The notch that reaches the edge is consumed (partial movement, stops exactly at the edge); the next notch chains — the same stepping a native scroller has. Set behaviorOptions.overscrollBehavior: "contain" to keep the pre-2.7.0 trapping (e.g. a list inside a modal where the page behind must never move).

⚠️ onWheel cannot do this. React 19 registers onWheel as a passive listener, so preventDefault() is ignored and the page scrolls instead. The hook attaches a { passive: false } listener itself.

⚠️ Only bridge elements OUTSIDE the pane. Anything inside the pane is already covered by the pane's own listener, so bridging it is a redundant registration — the consumed-event mark keeps it from being applied twice, but the extra listener is pure overhead on the wheel hot path.

⚠️ Do not bridge with scrollTo. It is a jump API: it floors the absolute position (sub-pixel trackpad deltas vanish entirely) and pins a scroll anchor (every later size change re-pins the list to that row, so filtering a list makes it jump back to where it used to be). scrollBy is the delta API and takes the pane's own path. Also never write scrollTo(handle.getScrollPosition() + delta) — the getter returns the -1 sentinel while the pane is unconnected.

Horizontal scrolling from the keyboard

With onWheelHorizontal wired and behaviorOptions.enableKeyboardNavigation left on (the default), horizontalKeyInputs lets a focused row emit horizontal deltas too:

| Value | Keys | Use it when | | --- | --- | --- | | [] (default) | — | Never steal the row's own arrow handling | | ["arrow"] | ←/→ | Lists that want keyboard access to the horizontal axis |

Shift + ←/→ is never consumed (the "shift-arrow" input was removed in 3.0.0): in the browser it extends the text selection, and the 2.x "yield only while a selection exists" detection had structural defects — selections inside Shadow DOM are invisible to document.getSelection() (the guard silently fails), and Ctrl+A makes every row intersect the selection, locking horizontal keys out until the selection is cleared. Shift-flavored horizontal input remains available via shift + wheel, which does not conflict with selection. The prop stays an array (one vocabulary entry today) to match behaviorOptions.pointerDragInputs and to keep future additions non-breaking.

The default is empty on purpose: the package's row key handler runs in the capture phase, ahead of your row's own handler. When it consumes a key it calls preventDefault() only and deliberately does not stop propagation — stopPropagation() would delete the event from document and window bubble listeners too, breaking global hotkey libraries and keydown telemetry. Check defaultPrevented in your own handler, exactly as you would for the vertical arrows.

It also fires only when the row itself is the event target. Focus sitting on something inside the row — a role="slider" cell, a link, a nested overflow-x:auto region — is never hijacked, so you do not have to avoid ["arrow"] just because your rows contain arrow-key widgets.

horizontalKeyStep (default 40) must be finite and positive; anything else logs a warning and leaves the key untouched rather than silently substituting the default.

⚠️ This does nothing when enableKeyboardNavigation is false — the row key handler is what reads these keys, and turning navigation off removes it. The package emits a Logger.warn when it sees that combination. Note the next section tells listbox / tree / grid authors to turn navigation off; if you follow both, your arrow keys are dead. Pick one.

⚠️ Rows are not in the page tab sequence (tabIndex={-1}). This feature fires only while a row has focus, so if you want keyboard-only users to reach it, give them an entry point — for example call handle.focusItemAtIndex(0) when the list receives focus.

⚠️ Wrappers that own the horizontal axis should seal these too. If you re-export VirtualScroll with Omit<VirtualScrollProps, 'onWheelHorizontal'>, add onPanHorizontal (3.2.0), horizontalKeyInputs and horizontalKeyStep to that same Omit. All four sit at the top level, next to each other, precisely so one Omit closes the whole horizontal seam.

Escape row-return

Rows are not in the tab order (tabIndex={-1}), so once focus enters a link or widget inside a row there is by default no keyboard way back to the row — the anchor for arrow-key navigation. behaviorOptions.enableEscapeRowReturn (default: false) makes Escape return focus to the row wrapper (scrolling it into view if needed).

It is off by default because Escape is the most overloaded key on the page (closing modals and dropdowns, cancelling IME composition, clearing selection). The feature therefore listens in the bubble phase — a dropdown opened inside a cell handles its own Escape first and can keep it with stopPropagation() or preventDefault() — skips IME composition (isComposing), leaves editable targets (input / textarea / select / contentEditable) entirely alone (their native Escape default actions, like Blink/WebKit clearing an <input type="search">, never show up in defaultPrevented and would otherwise be silently suppressed), and never acts while the row itself has focus (an Escape on the row means whatever you decide — e.g. deselect). When it does act it calls preventDefault() only and does not stop propagation, the same contract as the arrow keys.

Screen-reader live region

Virtualization removes off-screen rows from the DOM, so a screen-reader user scrolling the list gets no feedback about where they are. The opt-in liveRegion prop renders a visually hidden role="status" element (polite, atomic) next to the pane and updates it after the visible range settles:

<VirtualScroll
    liveRegion={{
        format: ({ visibleStartIndex, visibleEndIndex, itemCount }) => `rows ${visibleStartIndex + 1}-${visibleEndIndex + 1} of ${itemCount}`,
        debounceMs: 400, // default; trailing — announces once after the last range change
    }}
    ...
/>

The live region has no built-in strings — its wording and language are yours, and locale / labels do not touch it (the built-in catalog covers only the seven chrome strings, see Localization). format returning the same string leaves the DOM untouched; "" clears the region. An invalid debounceMs (negative / non-finite) logs a warning and disables announcements instead of silently substituting the default. ⚠️ If your app already maintains its own live region for the list, keep using onRangeChange instead — two regions double-announce.

Localization

The components render exactly seven strings of their own (UI chrome). They come from a built-in catalog in English ("en", the default) and Japanese ("ja"):

| Key | Where it appears | en | ja | | --- | --- | --- | --- | | scrollUp | vertical ScrollBar start arrow (aria-label) | Scroll up | 上へスクロール | | scrollDown | vertical ScrollBar end arrow (aria-label) | Scroll down | 下へスクロール | | scrollLeft | horizontal ScrollBar start arrow (aria-label) | Scroll left | 左へスクロール | | scrollRight | horizontal ScrollBar end arrow (aria-label) | Scroll right | 右へスクロール | | scrollToTop | VirtualScroll scroll-to-top pill (enableScrollToTopBottomButtons) | Top | 先頭へ | | scrollToBottom | VirtualScroll scroll-to-bottom pill (enableScrollToTopBottomButtons) | Bottom | 末尾へ | | noItems | VirtualScroll empty state (itemCount === 0; also VirtualGrid with no scroll rows) | No items | 項目がありません |

Pick the language with locale and override single keys with labels, which are laid over the catalog of locale:

<VirtualScroll locale="ja" labels={{ noItems: "データなし" }} ... />
  • The same two props exist on ScrollBar, ScrollPane, VirtualScroll and VirtualGrid. ScrollPane passes them to its bar, VirtualScroll to its pane, and VirtualGrid to both its embedded VirtualScroll and its horizontal bar. Each component reads only the keys it renders.

  • Omitting locale renders English, and the DOM is byte-identical to locale="en".

  • Fail-fast: an unsupported locale ("EN", "ja-JP", "fr", "", null, ...) throws a RangeError at render. So do an unknown labels key and a present value that is not a string with non-whitespace content. An undefined value keeps the catalog text.

  • labels must be a plain object: its prototype must be Object.prototype or null (Object.create(null) is fine). Arrays (even []), class instances, Dates and objects created with Object.create(proto) throw a RangeError, because the unknown-key check sees only own keys and would otherwise silently ignore a typo on the prototype. Only own properties are read, so a label key inherited from a polluted Object.prototype is never applied.

  • Error messages describe the rejected value without throwing: a string is shown as JSON (got "ja-JP"), null as got null, and anything else only by its typeof tag (got number, got bigint, got symbol, got object, got function) — never by its value. BigInt, Symbol and null-prototype inputs therefore still produce the documented RangeError, not a TypeError from stringifying them.

  • No language negotiation: the package never reads navigator.language. Map it yourself and pass a supported value:

    import { VIRTUAL_SCROLL_LOCALES, type VirtualScrollLocale } from '@aiquants/virtualscroll'
    
    // Your app decides what an unsupported language maps to — here English.
    const toLocale = (tag: string): VirtualScrollLocale => {
      const primary = tag.split('-')[0].toLowerCase()
      const match = VIRTUAL_SCROLL_LOCALES.find((locale) => locale === primary)
      return match === undefined ? 'en' : match
    }
    
    <VirtualScroll locale={toLocale(navigator.language)} ... />
  • No lang attribute is stamped. The document language belongs to the host: set <html lang> (or a lang on an ancestor when the list's language differs from the page).

  • Live-region wording stays yours (see Screen-reader live region).

Exported API: VIRTUAL_SCROLL_LOCALES (["en", "ja"]), VIRTUAL_SCROLL_LABEL_KEYS (the seven keys), VIRTUAL_SCROLL_LABEL_CATALOGS (the frozen catalogs), resolveVirtualScrollLocale(locale) (undefined → "en", otherwise the value or a RangeError), resolveVirtualScrollLabels(locale, labels) (the effective frozen labels — the catalog object itself when labels is undefined), and the types VirtualScrollLocale, VirtualScrollLabels and VirtualScrollLabelOverrides.

VirtualGrid (2D — trillion-scale columns, v3.2.0)

VirtualGrid composes the proven row axis (an embedded VirtualScroll) with a symmetric horizontal column engine built from the SAME machinery: a second sparse Fenwick width tree, the shared rendering-range computation (BigInt huge branch included), quantized column-anchor rebasing at the shared ANCHOR_REBASE_DISTANCE, and synthetic scrolling — total-size pixels never land in the DOM, so colCount inherits the full row-axis profile (<= 2^53 - 1; ten trillion columns sit comfortably inside).

<VirtualGrid<string>
    rowCount={1_000_000_000_000}
    colCount={1_000_000_000_000}
    getRowHeight={() => 24}
    getColWidth={() => 100}
    getCell={(row, col) => `${row}:${col}`}
    contentProps={{ role: "grid" }}          // composite role belongs to the consumer
    getCellProps={(row, col) => ({ role: "gridcell" })}
    onRenderTruncated={(info) => console.warn(info)} // the cell cap is never silent
>
    {(cell) => <span>{cell}</span>}
</VirtualGrid>

Contracts (see the design plan under docs/plans/ for the full bounding theorem):

  • getRowHeight / getColWidth return integer px, 0 = hidden, <= MAX_TRACK_SIZE (2^18 px) — oversized tracks fail fast with a RangeError (a single unbounded track would pierce the measured browser layout wall at 2^25 px).
  • Per-axis overscan defaults to 3 (a deliberate, documented deviation from the bare VirtualScroll default of 15 — the bounding contract's window-span term is overscan-proportional). With the defaults, ancestor scale(z) stays in the full-precision band up to z ≈ 5.3.
  • The handle mirrors the row contracts: logical coordinates, -1 sentinels pre-attach, scrollBy/scrollTo return the applied clamped position synchronously, applyWheel returns a boolean for useWheelBridge, and {index, offset} anchors (getScrollAnchor / initialScrollAnchor) replace raw-px restore.
  • Header sync: derive the window origin (offsetOf(renderingColStart)) from YOUR geometry source, position header cells window-relative, and translate the header track by origin - scrollX per onScroll — the same scheme the row-side headers use. LTR only (v1).
  • Row-side parity carries: horizontalKeyInputs / horizontalKeyStep (arrow keys reach the grid-owned horizontal axis), contentInsets, background, onRowFocus / focusRowAtIndex, and behaviorOptions.defaultColWidth (explicit Fenwick baseValue — skips width sampling). The full member-by-member classification lives in docs/specs/2026.09.01 [AI] virtualscroll-virtual-grid.md.
  • locale / labels (see Localization) are forwarded to BOTH the embedded VirtualScroll (vertical arrows and the "No items" empty state shown for 0 rows, an all-frozen row set or a degenerate band) and the horizontal ScrollBar (left / right arrows).
  • Frozen leading columns (v3.3.0): frozenLeadingCols pins the first F columns as a static band OUTSIDE the anchor machinery (direct tree-absolute lefts; scroll cells live inside a static clip whose inner carries the residual - W_F origin-shift transform). Integer in [0, MAX_FROZEN_LEADING_COLS] (128) — RangeError otherwise; values beyond the current colCount freeze every column (documented dynamic clamp). scrollToCell / initialScrollAnchor targeting a frozen column are horizontal no-ops (always visible), and getFrozenSize() reports the effective {cols, width, rows, height, trailingCols, trailingRows, trailingWidth, trailingHeight, trailingVisibleWidth, trailingVisibleHeight} (both axes, both ends — the rows/height fields are a non-breaking v3.4.0 addition and the six trailing* fields a non-breaking v3.5.0 addition; trailingWidth/trailingHeight are TREE px, the trailingVisible* pair is the viewport clip min(tree, max(0, viewport - leading))) for consumer hit-test partitioning. Frozen cells precede the scroll band in row DOM (logical column order for AT / tabbing), and an all-frozen grid reports the scroll band as the canonical EMPTY window (`renderingColStart

    renderingColEnd— inclusive loops run zero iterations).frozenLeadingColsof0` (the default) is structurally identical to the pre-frozen DOM.

  • Frozen leading rows (v3.4.0): frozenLeadingRows pins the first R rows as a grid-owned band ABOVE the scroll pane (out-of-pane band + index shift: the embedded row axis only ever sees the shifted space rowCount - R, so the vertical bar maps the scroll band naturally — no bar inset needed). Band rows reuse the normal row renderer, so frozen columns keep working inside them (the frozen-rows x frozen-cols 4-quadrant corner comes for free). Integer in [0, MAX_FROZEN_LEADING_ROWS] (128) — RangeError otherwise; values beyond the current rowCount freeze every row (documented dynamic clamp — the scroll rows then report the canonical EMPTY window renderingRowStart > renderingRowEnd), as does a degenerate band (H_F fills the measured viewport — zero scroll-row DOM). scrollToCell / initialScrollAnchor targeting a frozen row are vertical no-ops (always visible), contentInsets.top remains leading blank space INSIDE the scroll band (a pinned band is not an inset), and frozenLeadingRows of 0 (the default) is structurally identical to the pre-frozen DOM. Changing frozenLeadingRows at runtime rebuilds the embedded row tree (remount) and preserves the visible-top viewpoint across the toggle.
  • Frozen trailing columns / rows (v3.5.0): frozenTrailingCols pins the last T columns in a right-anchored clip outside the anchor machinery (band-local lefts; the scroll band generalizes to viewport - W_F - W_T with a right clip inset driven by --aqvs-grid-trailing-width — written only while T > 0, 0px fallback otherwise), and frozenTrailingRows pins the last T rows as a second grid-owned band BELOW the scroll pane (clip height = the visible size min(H_T, max(0, viewport - H_F)), inner bottom-anchored at the tree height H_T). Band rows reuse the normal row renderer, so every corner of the 3 x 3 region grid comes for free. Integers in [0, MAX_FROZEN_TRAILING_COLS] / [0, MAX_FROZEN_TRAILING_ROWS] (128) — RangeError otherwise; the dynamic clamp is min(T, count - effectiveLeading): the LEADING band wins the count space (ADR-19-1), and when the leading + trailing extents exceed the viewport, occluded trailing cells cannot be scrolled into view (a documented clamp — scrollToCell / initialScrollAnchor targeting a trailing track stay no-ops on that axis, the column no-op leaving any armed column anchor in place). Changing frozenTrailingCols/frozenTrailingRows at runtime does NOT remount the embedded row axis (the tail-only itemCount change keeps the viewpoint for free; a pending row anchor whose target enters the band clamps to the last scroll row), and a value of 0 (the default) is structurally identical to the pre-trailing DOM.

Residual quantizer for snapping consumers

Consumers that snap positions to a fixed quantum (row height, column width) lose sub-quantum deltas: a high-resolution trackpad emits many small deltas, each rounds to zero rows, and a slow swipe never moves. createResidualQuantizer carries the remainder across events:

import { createResidualQuantizer } from "@aiquants/virtualscroll"

const quantizer = createResidualQuantizer({ quantum: ROW_HEIGHT })
const onWheelVertical = (deltaY: number) => {
    const emitted = quantizer.push(deltaY) // always a multiple of the quantum (possibly 0)
    if (emitted !== 0) snapScrollBy(emitted)
}

By default the residue is dropped on direction reversal (so a stale forward remainder cannot delay the first backward step); call reset() from your own idle timer or when the snapped layout changes. quantum must be finite and positive (anything else throws — fail fast). Non-finite deltas and deltas beyond Number.MAX_SAFE_INTEGER emit 0 without poisoning the accumulator — past 2^53 the float product stops being an exact multiple of the quantum, the very property a snapping consumer relies on. The quantizer is deliberately time-free: idle expiry policies differ per UI, so they stay on your side of the seam.

Suspended transitions (React 18/19 startTransition + Suspense)

Scroll state is judged against the last committed dimensions, never against a render that got discarded. While a transition that shrinks or grows the list is suspended (the old list stays on screen), the wheel keeps working and every jump/clamp uses the sizes the user can actually see — a pending shrink cannot snap the list to the top, and a pending grow cannot let you overscroll into space that does not exist yet. Once the transition commits, positions re-clamp against the new sizes as usual. Manual scrolling during the pending window also detaches a pending scrollTo/scrollToIndex anchor exactly like it does outside a transition, so the commit does not yank the list back.

(Regression-tested in jsdom and real Chromium: src/transitionClamp.spec.tsx, tests/e2e/transition-scroll.spec.ts.)

Composite widget roles (listbox / tree / grid)

Building a listbox, tree or grid on top of VirtualScroll? Put the role on contentProps, not on a wrapper around <VirtualScroll>:

<VirtualScroll
  itemCount={options.length}
  getItem={(i) => options[i]}
  getItemHeight={() => 36}
  viewportSize={300}
  contentProps={{ id: listboxId, role: "listbox", "aria-label": "Warehouses" }}
  // Required: with keyboard navigation ON, every row wrapper gets tabIndex={-1}. That does not add a
  // tab stop, but it makes the wrappers focusable — VirtualScroll then moves real DOM focus onto a row
  // — and it makes them opaque to the owned-element relationship.
  // ⚠️ This also disables `horizontalKeyInputs` — the row key handler goes away with it.
  behaviorOptions={{ enableKeyboardNavigation: false }}>
  {(option) => <div role="option" aria-selected={false}>{option.label}</div>}
</VirtualScroll>

Why the content element and not the root. The root also contains the custom scrollbar, whose role="scrollbar" is not an allowed owned child of listbox — ARIA permits only option and group. The content element owns exactly the rendered rows; the scrollbar and any overlay are its siblings. Passing id here also overrides the generated one, so the scrollbar's aria-controls keeps pointing at the same element.

The wrappers between the content element and your rows carry no role, no global ARIA attribute and no tabindex, so they are transparent to the owned-element relationship — but only while enableKeyboardNavigation is false. That is what a consumer driving selection through aria-activedescendant should be doing anyway: with it on, VirtualScroll moves real DOM focus onto a row, which the APG combobox/listbox patterns forbid.

For a virtualized list you must also publish aria-setsize (the full count) and aria-posinset (the absolute 1-based index) on every row — a browser cannot infer them when only a window of rows exists in the DOM.

API Reference

VirtualScroll Props

| Prop | Type | Required | Description | | --- | --- | --- | --- | | children | (item: T, index: number) => ReactNode | ✅ | Render function for items | | itemCount | number | ✅ | Total number of items | | getItem | (index: number) => T | ✅ | Function to get item at index | | getItemHeight | (index: number) => number | ✅ | Function to get item height | | viewportSize | number | ❌ | Height of the visible band. Omit it — the component then measures its host with a ResizeObserver. Pass it only when you know the band by calculation rather than measurement (e.g. a dropdown sized from row count × row height). See Sizing | | overscanCount | number | ❌ | Number of items to render outside viewport (default: 15) | | className | string | ❌ | CSS class name. Lands on the scroll root (.aqvs-scroll-pane) — see Custom Scrollbar Styling | | getItemKey | (index: number) => React.Key | ❌ | Stable React key per index (defaults to the index) | | testId | string | ❌ | Emitted as data-testid on the scroll root. DOM hooks should use data-*, never class selectors | | onScroll | (position: number, totalHeight: number) => void | ❌ | Scroll event handler | | onRangeChange | (range: VirtualScrollRange) => void | ❌ | Range change handler | | onWheelHorizontal | (deltaX: number) => void | ❌ | Opt-in horizontal delegation. Horizontal-dominant wheel / trackpad gestures (and shift+wheel) are delegated to this handler so the parent can implement horizontal scrolling. ⚠️ Keyboard deltas arrive here too when horizontalKeyInputs is set. When omitted, horizontal gestures bypass vertical scrolling so parent native scrolling works. See Horizontal Scrolling. | | onPanHorizontal | (deltaX: number) => void | ❌ | Opt-in delegation of the horizontal component of pointer pans (touch / pen drags) — the pan twin of onWheelHorizontal with the same sign convention, per-move increments divided by the press-time x-axis ancestor scale (3.2.0). When set, purely horizontal pans also activate the drag. Same seal family as onWheelHorizontal — wrappers sealing the horizontal axis must Omit this name too. | | horizontalKeyInputs | readonly "arrow"[] | ❌ | Keyboard gestures that emit a horizontal delta through onWheelHorizontal (default: [], so row-level arrow handling is never stolen). Shift + ←/→ is never consumed. Fires only when the row itself is the event target. ⚠️ Requires behaviorOptions.enableKeyboardNavigation (default true) — with it off the rows have no key handler at all and this prop does nothing (the package warns) | | horizontalKeyStep | number | ❌ | Pixels per horizontal arrow press (default: 40). Must be finite and positive; anything else warns and leaves the key untouched | | background | ReactNode | ❌ | Background element | | initialScrollIndex | number | ❌ | Initial scroll index | | initialScrollOffset | number | ❌ | Initial scroll offset (logical px). Not suitable for restoring positions of variable-height lists across remounts — use initialScrollAnchor | | initialScrollAnchor | { index: number; offsetPx?: number } | ❌ | Anchor-based initial position (1.25.0): starts with row index scrolled offsetPx px past the viewport top, exact from the first commit. Save side: getScrollAnchor(). Precedence: anchor > initialScrollIndex > initialScrollOffset | | contentInsets | ScrollPaneContentInsets | ❌ | Insets for the content area | | callbackThrottleMs | number | ❌ | Throttle time for scroll callbacks (default: 5ms) | | onItemFocus | (index: number) => void | ❌ | Callback when an item is focused | | scrollBarOptions | VirtualScrollScrollBarOptions | ❌ | Options for the scrollbar | | behaviorOptions | VirtualScrollBehaviorOptions | ❌ | Options for scrolling behavior | | liveRegion | VirtualScrollLiveRegionOptions | ❌ | Opt-in screen-reader live region announcing the visible range (nothing rendered when omitted). See Screen-reader live region | | contentProps | React.AriaAttributes & { id?: string; role?: React.AriaRole } | ❌ | ARIA / identity attributes for the scrollable content element — the correct host for a composite widget role. See Composite widget roles. | | locale | VirtualScrollLocale ("en" \| "ja") | ❌ | UI chrome language of the built-in strings: arrow labels, scroll-to-edge pills, empty state (default: "en"). Unsupported values throw a RangeError. See Localization | | labels | VirtualScrollLabelOverrides | ❌ | Per-key overrides laid over the catalog of locale: a plain object (prototype Object.prototype or null) whose own properties only are read. Unknown keys, blank or non-string values, and non-plain objects (arrays, class instances) throw a RangeError. See Localization |

VirtualScrollScrollBarOptions

| Property | Type | Description | | --- | --- | --- | | width | number | Width of the scrollbar (default: 12) | | enableThumbDrag | boolean | Enable dragging the scrollbar thumb (default: true) | | enableTrackClick | boolean | Enable clicking the scrollbar track (default: true) | | enableArrowButtons | boolean | Enable arrow buttons on the scrollbar (default: true) | | enableScrollToTopBottomButtons | boolean | Enable the auto-hiding Top/Bottom pills (texts from labels.scrollToTop / labels.scrollToBottom; default: false) | | renderThumbOverlay | (props: ScrollBarThumbOverlayRenderProps) => ReactNode | Render prop to anchor custom UI near the scrollbar thumb | | tapScrollCircleOptions | ScrollBarTapCircleOptions | Customization for the auxiliary tap scroll circle |

VirtualScrollBehaviorOptions

| Property | Type | Description | | --- | --- | --- | | enablePointerDrag | boolean | Enable dragging the content area to scroll (default: true). ⚠️ Setting this to false removes the only way to scroll on touch devices — the pane is transform-based and has no native scroller. Use pointerDragInputs to exclude a single pointer type instead. | | pointerDragInputs | readonly ("mouse" \| "pen" \| "touch")[] | Pointer types allowed to drag-scroll the content area (default: all three). touch-action: none is applied only when "touch" or "pen" is included. | | enableKeyboardNavigation | boolean | Enable keyboard navigation (default: true). Arrow / Page keys move row focus. ⚠️ They fire only when the row wrapper itself is the event target — focus inside a row (a role="slider" cell, a link, a nested scroll region) keeps its own keys | | enableEscapeRowReturn | boolean | Opt-in (default: false): pressing Escape while focus sits on an element inside a row returns focus to the row wrapper. Listens in the bubble phase and respects preventDefault / stopPropagation / IME composition, so widgets inside the row keep first claim on their own Escape. Requires enableKeyboardNavigation | | wheelSpeedMultiplier | number | Multiplier for mouse wheel scrolling speed (default: 1) | | inertiaOptions | ScrollPaneInertiaOptions | Physics tuning for drag inertia | | overscrollBehavior | "auto" \| "contain" | Wheel behavior at the edges (default "auto" = chain to ancestors like a native scroller; "contain" = keep consuming, pre-2.7.0 trapping) | | clipItemHeight | boolean | Whether to clip item height (default: false) | | resetOnGetItemHeightChange | boolean | Whether to reset internal height cache when getItemHeight changes (default: false) |

VirtualScrollHandle Methods

| Method | Type | Description | | --- | --- | --- | | scrollTo | (position: number \| ((prev: number) => number)) => number | Jump to a logical position (updater receives the current logical position). Returns the applied logical position (2.0.0). ⚠️ Not for bridging continuous input — see Bridging input from outside the pane | | scrollBy | (delta: number) => number | Scroll by a delta with the pane's own wheel semantics (float accumulation, no anchor). Use this — not scrollTo — to bridge continuous input from outside the pane. Returns the applied logical position | | applyWheel | (event: WheelEvent) => boolean | Apply one wheel event with the pane's own rules; returns whether it was consumed. The single entry point for wheel input originating outside the pane — normally reached through useWheelBridge | | (ScrollPaneHandle only) scrollTo | (pos, dimsOverride?) | The pane-level jump. dimsOverride is an explicit clamp-space for callers that know fresher-than-committed dims (the layout-shift compensation passes committed + delta). Do not pass it casually — a wrong override clamps against dims that are not on screen | | scrollToIndex | (index: number, options?: { align?: "top" \| "bottom" \| "center"; offset?: number }) => void | Scroll to specific item index with optional alignment and offset | | getScrollPosition | () => number | Get current logical scroll position (2.0.0; -1 when the pane is not connected) | | getContentSize | () => number | Get total content size (insets included). Returns the -1 sentinel while the pane is unconnected | | getViewportSize | () => number | Get viewport size. Returns the -1 sentinel while the pane is unconnected | | focusItemAtIndex | (index: number, options?: { ensureVisible?: boolean }) => void | Focus item at specific index | | getRange | () => VirtualScrollRange | Get current range information (updated one render behind) | | getScrollAnchor | () => { index: number; offsetPx: number } \| null | Capture the current top visible row anchor for exact restore via initialScrollAnchor (null when itemCount is 0) | | getFenwickTreeTotalHeight | () => number | Total content height managed by the Fenwick tree (insets excluded) | | getFenwickSize | () => number | Item count managed by the Fenwick tree | | updateItemSize | (index: number, size: number) => void | Manually update one item's size (with layout-shift compensation). getItemHeight(index) must return the same value afterwards |

Coordinate system (2.0.0): every position the handle and callbacks accept or return is logical (content px, insets excluded) — onScroll / onRangeChange / initialScroll* / getScrollAnchor / scrollTo (input AND return) / getScrollPosition all share one space, so no conversion code is ever needed in consumers. Pane coordinates (insets included) are an internal detail; only ScrollPane's own handle speaks pane-space (it IS the pane). Logical 0 maps to pane 0 internally (true top, top inset visible; unified in 1.24.0).

VirtualScrollRange

| Property | Type | Description | | --- | --- | --- | | renderingStartIndex | number | Index of the first item being rendered (including overscan) | | renderingEndIndex | number | Index of the last item being rendered (including overscan) | | visibleStartIndex | number | Index of the first fully or partially visible item | | visibleEndIndex | number | Index of the last fully or partially visible item | | scrollPosition | number | Current scroll position in pixels | | totalHeight | number | Total height of the scroll content |

Advanced Usage

With Ref and Scroll Control

// Load the stylesheet once, e.g. in your app entry point (see the styling note above)
import '@aiquants/virtualscroll/styles/virtualscroll.standalone.css'
import { ScrollBarThumbOverlayRenderProps, VirtualScroll, VirtualScrollHandle, VirtualScrollRange } from '@aiquants/virtualscroll'
import { useCallback, useRef, useState } from 'react'

const items = Array.from({ length: 100000 }, (_, index) => ({
  id: index,
  text: `Item ${index}`,
  height: (index % 20) * 2 + 30,
}))

const getItem = (index: number) => items[index]
const getItemHeight = (index: number) => items[index].height

function AdvancedExample() {
  const virtualScrollRef = useRef<VirtualScrollHandle>(null)
  const [visibleStartIndex, setVisibleStartIndex] = useState(0)

  const scrollToTop = () => {
    virtualScrollRef.current?.scrollTo(0)
  }

  const scrollToIndex = (index: number) => {
    // Scroll to item 500, aligning it to the center of the viewport
    virtualScrollRef.current?.scrollToIndex(index, { align: "center" })
  }

  const handleRangeChange = useCallback((range: VirtualScrollRange) => {
    setVisibleStartIndex(range.visibleStartIndex)
  }, [])

  const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => {
    if (!(props.isDragging || props.isTapScrollActive)) {
      return null
    }
    const activeItem = items[visibleStartIndex]
    const label = activeItem ? activeItem.text : `Item ${visibleStartIndex}`

    return (
      <div
        style={{
          pointerEvents: 'none',
          position: 'absolute',
          display: 'flex',
          alignItems: 'center',
          ...(props.orientation === 'vertical'
            ? { top: props.thumbCenter, left: -14, transform: 'translate(-100%, -50%)' }
            : { left: props.thumbCenter, top: -14, transform: 'translate(-50%, -100%)' }),
        }}
      >
        <div
          style={{
            borderRadius: 9999,
            border: '1px solid #e2e8f0',
            background: '#fff',
            padding: '4px 8px',
            fontSize: 12,
            fontWeight: 500,
            color: '#334155',
            whiteSpace: 'nowrap',
            boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
          }}
        >
          {label}
        </div>
      </div>
    )
  }, [visibleStartIndex])

  return (
    <div>
      <div>
        <button onClick={scrollToTop}>Scroll to Top</button>
        <button onClick={() => scrollToIndex(500)}>Scroll to Item 500</button>
      </div>
      <VirtualScroll
        ref={virtualScrollRef}
        itemCount={items.length}
        getItem={getItem}
        getItemHeight={getItemHeight}
        onRangeChange={handleRangeChange}
        scrollBarOptions={{
          renderThumbOverlay: renderThumbOverlay
        }}
      >
        {(item, index) => <ItemComponent item={item} index={index} />}
      </VirtualScroll>
    </div>
  )
}

Custom Scrollbar Styling

className lands on the scroll root (.aqvs-scroll-pane). The bar, its track, thumb, arrow buttons and the scroll-to-edge pills are all descendants of that element, so one wrapper class reaches every part:

<VirtualScroll
  itemCount={items.length}
  getItem={getItem}
  getItemHeight={getItemHeight}
  viewportSize={400}
  className="custom-virtual-scroll"
>
  {(item, index) => <ItemComponent item={item} index={index} />}
</VirtualScroll>
/* The bar and its track. The stripe you see behind the thumb is the track. */
.custom-virtual-scroll .aqvs-scrollbar { background-color: #f0f0f0; }
.custom-virtual-scroll .aqvs-scrollbar-track { background-color: #e8e8e8; }

/* The thumb. Restate the states you want to keep — see "State rules are replaced" below. */
.custom-virtual-scroll .aqvs-scrollbar-thumb { background-color: #007acc; }
.custom-virtual-scroll .aqvs-scrollbar-thumb[data-thumb-state="hover"] { background-color: #3399dd; }
.custom-virtual-scroll .aqvs-scrollbar-thumb[data-thumb-state="dragging"] { background-color: #005c99; }
.custom-virtual-scroll .aqvs-scrollbar-thumb[data-thumb-state="disabled"] { background-color: #b0c4d4; }

/* Arrow buttons and the scroll-to-edge pills. */
.custom-virtual-scroll .aqvs-scrollbar-arrow-button { background-color: #e8e8e8; color: #007acc; }
.custom-virtual-scroll .aqvs-scrollbar-arrow-button:enabled:hover { background-color: #cfe8f7; }
.custom-virtual-scroll .aqvs-scroll-to-edge-button { background-color: rgba(0, 122, 204, 0.85); }
.custom-virtual-scroll .aqvs-scroll-to-edge-button:hover { background-color: rgba(0, 92, 153, 0.9); }

The packaged rules live in @layer components in both artifacts, so an ordinary (unlayered) rule in your app wins regardless of specificity and regardless of import order — no !important, no deeper selector. Keep your rules unlayered: written inside a @layer components { … } block of your own you re-enter the package's layer, and ordinary specificity — then source order — decides again. The example above still wins there (every selector is prefixed with your wrapper class), but an unprefixed .aqvs-scrollbar-thumb { … } in that layer loses on hover and while dragging, and an equally specific rule loses outright if your stylesheet is ordered before the package's.

Three consequences are worth knowing:

  • State rules are replaced, not merged. The thumb publishes data-thumb-state (idle / hover / dragging / disabled; disabled whenever scrollBarOptions.enableThumbDrag is false). A single flat .aqvs-scrollbar-thumb { background-color: … } freezes the thumb at that one colour, and the same applies to .aqvs-scrollbar-arrow-button:enabled:hover and .aqvs-scroll-to-edge-button:hover. The shape feedback survives untouched: scaleX(1.06) / scaleX(1.12) on a vertical bar, scaleY(…) on a horizontal one — and the same state rules pull the cross-axis edges from 1.5px to -0.5px, which is most of the growth at the default width: 12.
  • Inline styles still outrank your rule. Every size and position — the bar's width, the arrow buttons, the thumb geometry — plus the thumb's border-radius / cursor and the track's border-radius are written inline from scrollBarOptions.width, viewportSize and the scroll position. Change sizing through the props; to re-round the corners use !important.
  • Do not hand-roll the hidden state. When the content fits the viewport, the bar and the thumb wrapper carry data-visible="false" and the packaged rule hides them (opacity: 0; pointer-events: none). If you override opacity on .aqvs-scrollbar, scope it to [data-visible="true"]. The same applies to the auto-hiding scroll-to-edge pills — and there the hidden state is more than styling: .aqvs-scroll-to-edge-overlay carries inert while hidden (and the pill tabindex="-1") so it is not a phantom tab stop for keyboard users. Forcing opacity: 1 on the hidden overlay therefore paints a pill that cannot be clicked or focused. Scope such rules to [data-visible="true"] as well.

The parts you are most likely to target: .aqvs-scroll-pane (root), .aqvs-scroll-pane-content (row viewport), .aqvs-scrollbar (+ -vertical / -horizontal), .aqvs-scrollbar-track, .aqvs-scrollbar-thumb-wrapper, .aqvs-scrollbar-thumb (+ -vertical / -horizontal), .aqvs-scrollbar-arrow-button, .aqvs-scroll-to-edge-overlay / -button, .aqvs-tap-scroll-circle, .aqvs-item-container.

Tap Scroll Circle Configuration

The auxiliary tap scroll circle can replace the native scrollbar for large datasets while remaining easy to control:

VirtualGrid ships a single two-axis circle since v3.6.0: instead of one circle per bar, the grid renders ONE grid-owned circle (axis="xy") near its far corner whose single drag vector scrolls both axes continuously (per-axis speeds follow each bar's law exactly on pure-axis drags). It reads the same scrollBarOptions.tapScrollCircleOptions knob (default offsets become -200/-200, corner-relative); enabled: false kills the single circle. Standalone VirtualScroll / ScrollPane / ScrollBar behavior is unchanged.

  • Adaptive speed scaling automatically ramps up to a 120× multiplier as itemCount grows (trillions supported).
  • Manual overrides let you clamp or extend speed via maxSpeedMultiplier when you need deterministic behavior.
  • Speed shaping with maxSpeedCurve controls how quickly the speed ramps up as you pull the circle, blending gentle near-threshold drag with high-speed travel at extended distances. The ceiling itself does not depend on the drag distance: exponentialSteepness shapes the ramp, and exponentialScale caps the ceiling (its effective value is min(exponentialScale, maxSpeedMultiplier)).
    • ⚠️ easedOffset is a fraction of the whole [minSpeed, maxSpeed] range applied at zero drag, not a small nudge. With a large maxSpeedMultiplier (including the automatic itemCount-derived one) even 0.1 starts the list at several viewports per second and costs you the low-speed resolution. Raise minSpeedMultiplier instead when you only want a faster floor.
  • Per-pointer drag control: behaviorOptions.pointerDragInputs restricts content drag-scrolling to specific pointer types, e.g. ["pen", "touch"] to keep touch scrolling while freeing the mouse for text selection. Do not use enablePointerDrag: false for that — the pane has no native scroller, so disabling drag removes the only way to scroll on touch devices.
  • Full layout control (size, offsetX, offsetY) keeps the circle accessible on both desktop and touch devices.
  • Visibility tuning exposes an opacity knob so you can match subdued or high-contrast UI themes.
import { VirtualScroll } from "@aiquants/virtualscroll"

const items = Array.from({ length: 1_000_000_000_000 }, (_, index) => ({
  id: index,
  text: `Record ${index}`,
  height: 42,
}))

export function UltraFastExample() {
  return (
    <VirtualScroll
      itemCount={items.length}
      getItem={(index) => items[index]}
      getItemHeight={() => 42}
      scrollBarOptions={{
        tapScrollCircleOptions: {
          maxSpeedMultiplier: 80, // Optional: override adaptive speed when needed
          maxSpeedCurve: {
            exponentialSteepness: 6,
            exponentialScale: 80,
          },
          offsetX: -96,
          opacity: 0.85,
        }
      }}
    >
      {(item) => <div style={{ height: item.height }}>{item.text}</div>}
    </VirtualScroll>
  )
}

Performance Tips

  1. Memoize callback functions: Use useCallback for getItem and getItemHeight
  2. Optimize item rendering: Memoize item components when possible
  3. Adjust overscan count: Balance between smooth scrolling and memory usage
  4. Consider item height consistency: More consistent heights provide better performance

Browser Support

  • Chrome 88+
  • Firefox 87+
  • Safari 14+
  • Edge 88+

License

MIT