@scrollstackjs/virtual
v0.1.2
Published
Headless list virtualization for ScrollStack — render 50 rows out of 50,000, with dynamic measurement, window scrolling and an infinite-scroll bridge. Framework-agnostic.
Maintainers
Readme
@scrollstackjs/virtual
Headless list virtualization for ScrollStack — render 50 rows out of 50,000. Dynamic row measurement, window or container scrolling, and a bridge that keeps loading pages once the sentinel a virtual list can't render is no longer an option. 2.70 KB gzipped, framework-agnostic, SSR-safe.
📖 Docs · API reference · Live demo
npm i @scrollstackjs/virtualUseful with or without the scroll engine — a static 50,000-row table needs no
pagination. Framework bindings ship with the adapters you already have:
@scrollstackjs/react/virtual, /vue/virtual, /svelte/virtual.
Quick start (React)
import { useVirtualizer } from '@scrollstackjs/react/virtual';
function Rows({ rows }: { rows: Row[] }) {
const { items, totalSize, scrollRef, measureRef } = useVirtualizer({
count: rows.length,
estimateSize: () => 48, // a ballpark; measured rows replace it
});
return (
<div ref={scrollRef} style={{ overflow: 'auto', height: 400 }}>
<div style={{ height: totalSize, position: 'relative' }}>
{items.map((item) => (
<div
key={item.key}
data-index={item.index}
ref={measureRef}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${item.start}px)`,
}}
>
{rows[item.index].label}
</div>
))}
</div>
</div>
);
}Three pieces do all the work: a spacer sized to totalSize so the scrollbar is
honest, item.start to place each row, and data-index + measureRef so rows of
unequal height correct their own estimates.
Headless (any framework)
import { createVirtualizer } from '@scrollstackjs/virtual';
const virtualizer = createVirtualizer({ count: rows.length, estimateSize: () => 48 });
virtualizer.setScrollElement(document.querySelector('#scroller')); // or `window`
virtualizer.subscribe(() => {
const { items, totalSize } = virtualizer.getSnapshot();
render(items, totalSize);
});The snapshot changes only when the rendered window changes, so scrolling within the current window costs a binary search rather than a render.
With infinite scrolling
A virtual list can't rely on a sentinel — the element after the last row usually
isn't in the DOM. connectInfiniteScroll watches the rendered window instead and
asks the engine for another page as it nears the end:
import { useInfiniteScroll } from '@scrollstackjs/react';
import { useVirtualizer } from '@scrollstackjs/react/virtual';
import { connectInfiniteScroll } from '@scrollstackjs/virtual';
function Feed() {
const { pages, engine } = useInfiniteScroll({
initialPageParam: 0,
fetchPage,
getNextPageParam: (last) => last.nextCursor,
});
// The only thing the two stores share is how many rows there are.
const rows = useMemo(() => pages.flatMap((page) => page.items), [pages]);
const { items, totalSize, scrollRef, measureRef, virtualizer } = useVirtualizer({
count: rows.length,
estimateSize: () => 64,
});
useEffect(
() => connectInfiniteScroll(virtualizer, engine, { threshold: 5 }),
[virtualizer, engine],
);
// …render as above, reading rows[item.index]
}It loads the first page too, leaves a failed load to the engine's retry policy, and requests one page per count so a slow API can't stack up duplicate requests.
Vue and Svelte wire up the same way — see the worked examples for all of them.
License
MIT
