@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
onWheelHorizontalhands 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-keylabelsoverrides. See Localization - 🔧 TypeScript: Full TypeScript support with comprehensive type definitions
Installation
npm install @aiquants/virtualscroll
# or
yarn add @aiquants/virtualscroll
# or
pnpm add @aiquants/virtualscrollNote: the
virtualscroll demoCLI is for source checkouts only — the demo app is not bundled in the published npm package. To run it, clone the repository and runpnpm install && pnpm devinsidedemo/.
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 basebut 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,VirtualScrollandVirtualGrid.ScrollPanepasses them to its bar,VirtualScrollto its pane, andVirtualGridto both its embeddedVirtualScrolland its horizontal bar. Each component reads only the keys it renders.Omitting
localerenders English, and the DOM is byte-identical tolocale="en".Fail-fast: an unsupported
locale("EN","ja-JP","fr","",null, ...) throws aRangeErrorat render. So do an unknownlabelskey and a present value that is not a string with non-whitespace content. Anundefinedvalue keeps the catalog text.labelsmust be a plain object: its prototype must beObject.prototypeornull(Object.create(null)is fine). Arrays (even[]), class instances,Dates and objects created withObject.create(proto)throw aRangeError, 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 pollutedObject.prototypeis never applied.Error messages describe the rejected value without throwing: a string is shown as JSON (
got "ja-JP"),nullasgot null, and anything else only by itstypeoftag (got number,got bigint,got symbol,got object,got function) — never by its value. BigInt, Symbol and null-prototype inputs therefore still produce the documentedRangeError, not aTypeErrorfrom 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
langattribute is stamped. The document language belongs to the host: set<html lang>(or alangon 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/getColWidthreturn integer px,0= hidden,<= MAX_TRACK_SIZE(2^18 px) — oversized tracks fail fast with aRangeError(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
VirtualScrolldefault of 15 — the bounding contract's window-span term is overscan-proportional). With the defaults, ancestorscale(z)stays in the full-precision band up to z ≈ 5.3. - The handle mirrors the row contracts: logical coordinates,
-1sentinels pre-attach,scrollBy/scrollToreturn the applied clamped position synchronously,applyWheelreturns a boolean foruseWheelBridge, 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 byorigin - scrollXperonScroll— 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, andbehaviorOptions.defaultColWidth(explicit Fenwick baseValue — skips width sampling). The full member-by-member classification lives indocs/specs/2026.09.01 [AI] virtualscroll-virtual-grid.md. locale/labels(see Localization) are forwarded to BOTH the embeddedVirtualScroll(vertical arrows and the "No items" empty state shown for 0 rows, an all-frozen row set or a degenerate band) and the horizontalScrollBar(left / right arrows).- Frozen leading columns (v3.3.0):
frozenLeadingColspins 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 theresidual - W_Forigin-shift transform). Integer in[0, MAX_FROZEN_LEADING_COLS](128) —RangeErrorotherwise; values beyond the currentcolCountfreeze every column (documented dynamic clamp).scrollToCell/initialScrollAnchortargeting a frozen column are horizontal no-ops (always visible), andgetFrozenSize()reports the effective{cols, width, rows, height, trailingCols, trailingRows, trailingWidth, trailingHeight, trailingVisibleWidth, trailingVisibleHeight}(both axes, both ends — therows/heightfields are a non-breaking v3.4.0 addition and the sixtrailing*fields a non-breaking v3.5.0 addition;trailingWidth/trailingHeightare TREE px, thetrailingVisible*pair is the viewport clipmin(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 (`renderingColStartrenderingColEnd
— inclusive loops run zero iterations).frozenLeadingColsof0` (the default) is structurally identical to the pre-frozen DOM. - Frozen leading rows (v3.4.0):
frozenLeadingRowspins 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 spacerowCount - 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) —RangeErrorotherwise; values beyond the currentrowCountfreeze every row (documented dynamic clamp — the scroll rows then report the canonical EMPTY windowrenderingRowStart > renderingRowEnd), as does a degenerate band (H_F fills the measured viewport — zero scroll-row DOM).scrollToCell/initialScrollAnchortargeting a frozen row are vertical no-ops (always visible),contentInsets.topremains leading blank space INSIDE the scroll band (a pinned band is not an inset), andfrozenLeadingRowsof0(the default) is structurally identical to the pre-frozen DOM. ChangingfrozenLeadingRowsat runtime rebuilds the embedded row tree (remount) and preserves the visible-top viewpoint across the toggle. - Frozen trailing columns / rows (v3.5.0):
frozenTrailingColspins the last T columns in a right-anchored clip outside the anchor machinery (band-local lefts; the scroll band generalizes toviewport - W_F - W_Twith a right clip inset driven by--aqvs-grid-trailing-width— written only while T > 0,0pxfallback otherwise), andfrozenTrailingRowspins the last T rows as a second grid-owned band BELOW the scroll pane (clip height = the visible sizemin(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) —RangeErrorotherwise; the dynamic clamp ismin(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/initialScrollAnchortargeting a trailing track stay no-ops on that axis, the column no-op leaving any armed column anchor in place). ChangingfrozenTrailingCols/frozenTrailingRowsat 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 of0(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;disabledwheneverscrollBarOptions.enableThumbDragisfalse). 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:hoverand.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 from1.5pxto-0.5px, which is most of the growth at the defaultwidth: 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/cursorand the track'sborder-radiusare written inline fromscrollBarOptions.width,viewportSizeand 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 overrideopacityon.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-overlaycarriesinertwhile hidden (and the pilltabindex="-1") so it is not a phantom tab stop for keyboard users. Forcingopacity: 1on 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 samescrollBarOptions.tapScrollCircleOptionsknob (default offsets become-200/-200, corner-relative);enabled: falsekills the single circle. StandaloneVirtualScroll/ScrollPane/ScrollBarbehavior is unchanged.
- Adaptive speed scaling automatically ramps up to a
120×multiplier asitemCountgrows (trillions supported). - Manual overrides let you clamp or extend speed via
maxSpeedMultiplierwhen you need deterministic behavior. - Speed shaping with
maxSpeedCurvecontrols 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:exponentialSteepnessshapes the ramp, andexponentialScalecaps the ceiling (its effective value ismin(exponentialScale, maxSpeedMultiplier)).- ⚠️
easedOffsetis a fraction of the whole[minSpeed, maxSpeed]range applied at zero drag, not a small nudge. With a largemaxSpeedMultiplier(including the automaticitemCount-derived one) even0.1starts the list at several viewports per second and costs you the low-speed resolution. RaiseminSpeedMultiplierinstead when you only want a faster floor.
- ⚠️
- Per-pointer drag control:
behaviorOptions.pointerDragInputsrestricts content drag-scrolling to specific pointer types, e.g.["pen", "touch"]to keep touch scrolling while freeing the mouse for text selection. Do not useenablePointerDrag: falsefor 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
opacityknob 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
- Memoize callback functions: Use
useCallbackforgetItemandgetItemHeight - Optimize item rendering: Memoize item components when possible
- Adjust overscan count: Balance between smooth scrolling and memory usage
- Consider item height consistency: More consistent heights provide better performance
Browser Support
- Chrome 88+
- Firefox 87+
- Safari 14+
- Edge 88+
License
MIT
