Skip to content

@scrollstackjs/virtual

Headless list virtualization: it decides which slice of a long list is worth rendering and where each row sits, and owns nothing else. No markup, no styles, no framework. 2.70 KB gzipped, SSR-safe, usable with or without the scroll engine.

bash
npm i @scrollstackjs/virtual
ts
import { createVirtualizer } from '@scrollstackjs/virtual'

Framework bindings ship with the adapters, behind a /virtual entry point: @scrollstackjs/react/virtual, /vue/virtual, /svelte/virtual. The guide is at Virtual lists.

createVirtualizer

ts
function createVirtualizer(options: VirtualizerOptions): Virtualizer

Returns a store in the same shape as the scroll engine — subscribe + getSnapshot, with referentially stable snapshots — so it binds with the same primitives.

ts
const virtualizer = createVirtualizer({ count: rows.length, estimateSize: () => 48 })
virtualizer.setScrollElement(document.querySelector('#scroller'))

virtualizer.subscribe(() => {
  const { items, totalSize } = virtualizer.getSnapshot()
  spacer.style.height = `${totalSize}px`
  render(items) // position each row at `item.start`
})

Throws a ScrollStackError when estimateSize or count is missing.

VirtualizerOptions

OptionTypeDefaultDescription
countnumberTotal items. Push a new one in with setOptions as pages land.
estimateSize(index: number) => numberEstimated row size along the scroll axis, in pixels. Measured rows replace it.
overscannumber3Extra rows rendered each side of the visible window.
horizontalbooleanfalseLay out along the x-axis.
paddingStartnumber0Space before the first row.
paddingEndnumber0Space after the last row.
gapnumber0Space between adjacent rows.
getItemKey(index: number) => ItemKeyindexRow identity. A stable key keeps a measured size with its row when the list is re-ordered.
scrollMarginnumber0Distance from the top of the scroll container to the top of the list. Needed for page-scrolled lists.
initialOffsetnumber0Scroll offset assumed before a container is attached (SSR, first paint).
initialViewportnumber0Viewport size assumed before a container is attached. Decides how much the server renders.
isScrollingDelaynumber150How long isScrolling stays true after the last scroll event. 0 disables it.
adjustScrollOnMeasurebooleantrueCompensate the scroll offset when a row above the viewport is measured a different size.

VirtualizerSnapshot

The value getSnapshot() returns. Same object reference until the rendered output changes — scrolling within the current window produces no new snapshot, and so no re-render.

FieldTypeDescription
itemsreadonly VirtualItem[]The rows to render, in order.
totalSizenumberSize of the whole list including padding — your spacer's height.
startIndexnumberIndex of the first rendered row (visible window minus overscan).
endIndexnumberIndex of the last rendered row, inclusive. -1 when the list is empty.
countnumberTotal rows the virtualizer knows about.
isScrollingbooleantrue from the first scroll event until isScrollingDelay ms after the last.

VirtualItem

FieldTypeDescription
indexnumberPosition in the full list — use it to look the row's data up.
keystring | numberStable identity. Use it as the framework key.
startnumberOffset from the start of the list, in pixels. Position rows with it.
endnumberstart + size.
sizenumberMeasured size when known, the estimate otherwise.

Virtualizer

MemberTypeDescription
getSnapshot() => VirtualizerSnapshotCurrent snapshot (stable reference).
subscribe(listener: () => void) => () => voidSubscribe to changes; returns unsubscribe.
setOptions(options: Partial<VirtualizerOptions>) => voidMerge new options. undefined leaves a field alone.
setScrollElement(target: Element | Window | null) => voidAttach the scroller. null detaches. Same target twice is a no-op.
measureElement(element: Element | null, index?: number) => voidRecord a row's real size and watch it for changes. null is ignored.
resetMeasurements() => voidDrop every measured size back to its estimate.
scrollToIndex(index: number, options?: ScrollToOptions) => voidScroll a row into view. Out-of-range indices are clamped.
scrollToOffset(offset: number, options?: { behavior }) => voidScroll to an absolute offset, clamped at 0.
getOffsetForIndex(index: number, align?: ScrollAlignment) => numberThe offset scrollToIndex would use, without scrolling.
getScrollOffset() => numberThe live scroll offset. Reading it never causes a render.
getViewportSize() => numberMeasured viewport size along the scroll axis.
destroy() => voidDetach listeners and observers. The DOM is untouched.

ScrollToOptions is { align?: 'auto' | 'start' | 'center' | 'end', behavior?: 'auto' | 'smooth' }. 'auto' alignment scrolls the shortest distance that reveals the row, and does nothing when it is already fully visible.

Measuring rows

measureElement takes the row's index from its data-index attribute, so one stable ref works for every row:

tsx
<div data-index={item.index} ref={measureRef} />

Pass the index explicitly (measureElement(node, item.index)) if you would rather not render the attribute. A ResizeObserver keeps watching each rendered row, so rows that change size later — an image loading, a "show more" toggle — correct themselves; rows are unwatched as they leave the window, and their measured sizes are kept.

An element with no layout box at all (zero width and zero height — a display: none subtree, or a row measured before first paint) is skipped rather than recorded as 0px. See Virtual lists.

connectInfiniteScroll

ts
function connectInfiniteScroll<TData, TPageParam>(
  virtualizer: Virtualizer,
  engine: InfiniteScroll<TData, TPageParam>,
  options?: { threshold?: number },
): () => void

Loads pages as the rendered window approaches the end of the list, replacing the sentinel a virtual list cannot render. Returns a disconnect function.

ts
const disconnect = connectInfiniteScroll(virtualizer, engine, { threshold: 5 })
OptionTypeDefaultDescription
thresholdnumber5How close to the end of the list the window must come, in items.

It also loads the first page, since nothing else will. Two guards prevent a fetch loop: a pending error is left to the engine's retry policy (call engine.retry() to resume), and one page is requested per count, so a batch that arrives before the binding has pushed the new count in doesn't stack up requests.

useVirtualizer (React)

ts
import { useVirtualizer } from '@scrollstackjs/react/virtual'

function useVirtualizer(options: UseVirtualizerOptions): UseVirtualizerResult

VirtualizerOptions plus an optional scrollElement, bound with useSyncExternalStore. Unlike useInfiniteScroll, options are read on every render — count grows as pages land, and a stale count renders the wrong window.

Returns everything on the snapshot, plus:

MemberTypeDescription
scrollRef(node: Element | null) => voidAttach to the scrolling element.
measureRef(node: Element | null) => voidAttach to every row, with data-index={item.index}.
scrollToIndex(index, options?) => voidScroll a row into view.
scrollToOffset(offset, options?) => voidScroll to an absolute offset.
virtualizerVirtualizerEscape hatch: the underlying virtualizer.

Pass scrollElement or attach scrollRef, not both. For a page-scrolled list, scrollElement: typeof window === 'undefined' ? null : window.

useVirtualizer (Vue)

ts
import { useVirtualizer } from '@scrollstackjs/vue/virtual'

count accepts a ref or a getter as well as a plain number — it is the option that changes while the list is on screen. Everything else is read once, at setup.

MemberTypeDescription
stateShallowRef<VirtualizerSnapshot>The live snapshot. Auto-unwraps in templates.
scrollTarget(el) => voidFunction ref for the scrolling element: :ref="scrollTarget".
measure(el) => voidFunction ref for each row: :ref="measure" :data-index="item.index".
setScrollElement(target) => voidAttach a scroller you already hold — window, typically.
scrollToIndex(index, options?) => voidScroll a row into view.
scrollToOffset(offset, options?) => voidScroll to an absolute offset.
virtualizerVirtualizerThe underlying virtualizer.

Both refs accept a component instance as well as an element, so rows may be components. Teardown is wired to the effect scope.

createVirtualizer (Svelte)

ts
import { createVirtualizer } from '@scrollstackjs/svelte/virtual'

Returns a value that is a store — $virtual gives you the snapshot — with the actions and controls attached.

MemberTypeDescription
scroller(node) => { destroy() }Action for the scrolling element: use:virtual.scroller.
measure(node) => { destroy() }Action for each row: use:virtual.measure data-index={item.index}.
setCount(count: number) => voidPush a new count in: $: virtual.setCount(rows.length).
setScrollElement(target) => voidAttach a scroller you already hold.
scrollToIndex(index, options?) => voidScroll a row into view.
scrollToOffset(offset, options?) => voidScroll to an absolute offset.
destroy() => voidFull teardown — onDestroy(virtual.destroy).
virtualizerVirtualizerThe underlying virtualizer.

Layout primitives

The pure geometry functions are exported for anyone building a different virtualizer on the same contracts — they hold no state and touch no DOM.

FunctionDescription
measure(previous, from, spec)Lays out items [from, count), reusing the prefix by reference.
totalSize(measurements, spec)Size of the whole list including padding.
findFirstVisible(measurements, offset)Binary search for the first row past offset.
computeRange(measurements, offset, viewport, overscan)The window to render.
offsetForIndex(measurements, index, align, offset, viewport, scrollMargin)The offset that aligns a row.