@hecto/virtual
v1.0.0
Published
Framework-free list virtualisation: window maths and a scroll-bound virtualizer with measured item sizes.
Readme
@hecto/virtual
Framework-free list virtualisation: window maths and a scroll-bound virtualizer with measured item sizes.
pnpm add @hecto/virtualWindow maths
computeVirtualWindow takes a scroll offset, a viewport size and an item count, and returns the
slice of indices that should be rendered — plus per-item offsets and the total scrollable size. It
works from an estimated size alone, or from a map of measured sizes once items have reported their
real height.
import { computeVirtualWindow } from '@hecto/virtual'
const window = computeVirtualWindow({
scrollOffset: 400,
viewportSize: 600,
itemCount: 10_000,
estimatedItemSize: 40,
overscan: 4,
})
// { start, end, offsets, totalSize }computeColumnWindow is the same maths for a horizontal axis (grid columns).
Virtualizer
createVirtualizer wraps the window maths around a real scroll element: it listens for scroll and
resize, tracks measured sizes via ResizeObserver, and notifies subscribers when the window moves.
import { createVirtualizer } from '@hecto/virtual'
const virtualizer = createVirtualizer({
count: () => items.length,
getScrollElement: () => scrollElement,
estimateSize: () => 40,
})
virtualizer.attach()
virtualizer.subscribe(() => render(virtualizer.getVirtualItems()))
// virtualizer.destroy() when doneReact
import { useRef } from 'react'
import { useVirtualizer } from '@hecto/virtual/react'
function List({ items }: { items: readonly string[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
scrollRef,
count: items.length,
estimateSize: () => 40,
})
return (
<div ref={scrollRef} style={{ overflow: 'auto', height: 500 }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => (
<div key={item.key} style={{ transform: `translateY(${item.start}px)` }}>
{items[item.index]}
</div>
))}
</div>
</div>
)
}Reusable geometry and measurements
Pass estimateSize: 40 for fixed sizes. The virtualizer then computes its window
without calling a per-row estimator. A callback remains live on every scroll for
compatibility. To cache a variable estimate snapshot, supply estimateRevision
(a string, number, or getter); change it when estimates change. Call measure()
for explicit invalidation. orderRevision similarly invalidates item order.
Initialization/invalidation can visit every row; ordinary scrolling through a
revisioned snapshot and individual measurements use logarithmic prefix geometry.
Measurements follow getItemKey(index) across reorders. Keys must be unique.
Detaching a row retains its measured size while the key remains in the collection;
removing a key or destroying the virtualizer releases that cache.
measureElement(index, element) returns a cleanup bound to that key and element;
use it for row disposal so a reordered or recycled index cannot detach another row. Reordering a
keyless list uses index identity. Call measure() or change orderRevision after
reordering. Row observations share one observer and resize deliveries batch into
one animation frame. scheduleScroll: true also batches scroll calculations;
scrollToIndex always flushes pending measurements synchronously.
scrollAnchoring: true compensates for height changes above the visible row.
getVirtualItems() reuses unchanged item and array snapshots. Unchanged windows
do not notify subscribers. Window offsets remain available for up to 10,000
rows and are materialized lazily once per geometry snapshot; larger lists expose
an empty offsets array and callers use the visible items' start/size fields.
Native View
Install @hecto/view alongside this package and import the owner-scoped
adapter from @hecto/virtual/view:
import { createVirtualizer } from '@hecto/virtual/view'
// Inside a View owner (component/render/createRoot):
const virtualizer = createVirtualizer({
count: () => items.get().length,
getScrollElement: () => scrollElement,
getItemKey: (index) => items.get()[index].id,
estimateSize: 40,
orderRevision: () => itemsRevision.get(),
})
// Explicitly attach after the scroll element exists; detach when its ref clears.
virtualizer.attach()
// Read virtualizer.items.get(), window.get(), and totalSize.get() in bindings.
// Owner disposal destroys listeners, observations, queued work and subscriptions.Use a numeric/string orderRevision that changes on reordering, or call
virtualizer.measure() after replacing the list. Render rows keyed by item.key;
do not recycle a stateful row owner under another key. List roles, positional
ARIA metadata and keyboard focus behavior belong to the consuming list.
