Why Scroll?
Rendering thousands of items as real DOM nodes freezes the browser. Each node consumes layout, paint, and memory — long lists need to render only what is visible in the viewport.
// Before — render all 10 000 items (browser freezes)
list.innerHTML = '';
items.forEach((item) => {
const el = document.createElement('div');
el.textContent = item.name;
list.appendChild(el); // 10 000 DOM nodes
});
// After — Scroll (only ~15 visible rows in the DOM at any time)
import { createVirtualizer } from '@vielzeug/scroll';
const virt = createVirtualizer(scrollEl, {
count: items.length,
estimateSize: 36,
onChange: ({ items, totalSize }) => {
list.style.height = `${totalSize}px`;
list.innerHTML = '';
for (const { index, start } of items) {
const el = document.createElement('div');
el.style.cssText = `position:absolute;top:${start}px;height:36px;`;
el.textContent = items[index].name;
list.appendChild(el);
}
},
});| Feature | Scroll | TanStack Virtual | react-window |
|---|---|---|---|
| Bundle size | 6.1 KB | ~5 kB | ~8 kB |
| Framework agnostic | React only | ||
| Variable heights | |||
| O(log n) lookup | |||
using support | |||
| Zero dependencies |
Use Scroll when you need to render large lists in a framework-agnostic environment with precise control over item measurement and scroll position.
Consider TanStack Virtual if you need its framework adapters and ecosystem integration.
Installation
pnpm add @vielzeug/scrollnpm install @vielzeug/scrollyarn add @vielzeug/scrollQuick Start
import { createVirtualizer } from '@vielzeug/scroll';
const scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;
const spacer = document.querySelector<HTMLElement>('.spacer')!;
const list = document.querySelector<HTMLElement>('.list')!;
const virt = createVirtualizer(scrollEl, {
count: 10_000,
estimateSize: 36,
onChange: ({ items, totalSize }) => {
// Stretch the container so the scrollbar reflects the full list
spacer.style.height = `${totalSize}px`;
list.innerHTML = '';
for (const item of items) {
const el = document.createElement('div');
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`;
el.textContent = `Row ${item.index}`;
list.appendChild(el);
}
},
});
// Clean up
virt.dispose();Entry Points
All APIs export from a single entry: @vielzeug/scroll.
Features
- Framework-agnostic — callback-based
onChangeconnects to any rendering layer (React, Vue, Svelte, Lit, vanilla DOM) - Fixed and variable heights — pass a fixed number, a per-index estimator function, or call
measure()after rendering for exact heights - Batched measurements — calling
measure()many times in a single tick coalesces into one prefix-sum rebuild viaqueueMicrotask - Stable-key reflow — call
refresh()after reorder/filter changes to rebuild offsets without discarding measured sizes - Sticky headers — mark items with
stickyto pin them at the viewport top;createGroupedVirtualizerhandles section headers automatically - Grouped sections —
createGroupedVirtualizervirtualizes sectioned data with per-section headers,onChangestate, andscrollToSection/scrollToItem - Grid virtualization —
createGridVirtualizervirtualizes two-dimensional data with independent row/column measurement andscrollToCell - Reactive integration —
createReactiveVirtualizerandcreateReactiveGroupedVirtualizerexpose state as aSignalcompatible with@vielzeug/ripple - DOM adapter —
createDomVirtualListandcreateVirtualScrollermanage virtualizer lifecycle, list-height styles, and DOM node pooling - Skipped re-renders —
onChangeis not called when a scroll event doesn't move the visible window across an item boundary - Programmatic scrolling —
scrollToIndex()withstart,end,center, andautoalignment;scrollToOffset()for pixel control;scrollToRow()/scrollToColumn()for grids; all supportbehavior: 'smooth' - Horizontal + window targets — supports both element and
windowscrolling, in vertical or horizontal mode - Asymmetric overscan + gap — tune start/end overscan independently and add inter-item spacing
- Atomic updates —
virt.update(...)lets you change count, estimator, overscan, and more in one call - Clamp-safe —
scrollToIndexsilently clamps out-of-range indices - Scroll state events —
onScrollingChangefires when scrolling starts/stops;onScrollEndfires once scrolling settles (nativescrollendor debounce fallback);isScrollinggetter available at any time - Scroll anchor — viewport position is preserved visually when
estimateSizechanges viaupdate() - Prepend support —
prepend()adds items at the top while keeping the viewport visually stable - Disposable — implements
[Symbol.dispose]forusingdeclarations - Zero runtime dependencies (ripple is a peer dependency used only by
createReactiveVirtualizer)
How It Works
Scroll maintains a prefix-sum offset array. On every scroll event it runs two binary searches — one for the first visible index, one for the last — to determine the render window in O(log n) time. Only the items within that window (plus overscan on each side) are passed to onChange.
Items: [0] [1] [2] [3] [4] [5] [6] ...
Offsets: 0 36 72 108 144 180 216 ...
scrollTop = 90, containerHeight = 120 → visible items 2–5
With overscan=3: render items 0–8The offset array is rebuilt (O(n)) only when layout inputs change: on measure() flush, refresh(), update({ count }), update({ estimateSize }), or invalidate(). Scroll and resize events recompute the visible window without rebuilding offsets.