@lacspace/virtual
v1.1.0
Published
Headless list virtualization for React — render only the rows in view, with fixed or dynamically-measured sizes, overscan, sticky/pinned rows, horizontal lists, and scroll-to-index. useVirtualizer in ~2KB, plus a React-free math core. Zero-dependency, SSR
Maintainers
Readme
@lacspace/virtual
Headless list virtualization for React — render 100k rows smoothly.
useVirtualizermounts only the rows in view, so a list of any size stays smooth. Fixed or dynamically-measured sizes, overscan, gaps, horizontal lists, sticky rows and scroll-to-index — in ~2 KB. You own the markup; the hook owns the math.
- 📜 Render 100k+ rows — only the visible window (plus overscan) is mounted
- 📏 Fixed or dynamically-measured item sizes (
measureElement+data-index) - ↔️ Vertical or horizontal · gaps · padding ·
scrollMarginfor window scrolling - 📌 Sticky / pinned indices — keep section headers rendered while scrolled away
- 🎯
scrollToIndex/scrollToOffsetwith alignment and smooth behaviour - 🧮 A React-free math core (
calculateRange,scrollToIndexOffset, …) — testable and usable without a DOM - ⚡ ~2 KB · headless (no components/CSS) · 🌍 SSR-safe · 📦 ESM + CJS · fully typed
New in 1.1.0 —
stickyIndicesto keep pinned rows (headers) mounted while out of view, and the whole geometry is now exported as a pure, React-free core —calculateRange,buildMeasurements,computeRange,scrollToIndexOffset,augmentRangeWithSticky— so you can run the exact virtualization math in a worker, on the server, or in a plain unit test with no DOM. Fully backward compatible; every existing export is unchanged.
Install
npm install @lacspace/virtualReact >=18 is a peer dependency.
Usage
A complete vertical list. A scroll container holds an inner spacer sized to the
full list; each visible row is absolutely positioned via translateY(item.start).
Adding data-index + ref={measureElement} opts each row into dynamic
height measurement (drop them for fixed heights).
import { useRef } from "react";
import { useVirtualizer } from "@lacspace/virtual";
function BigList({ rows }: { rows: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44, // best guess before measuring
overscan: 8,
});
return (
<div ref={parentRef} style={{ height: 480, overflow: "auto" }}>
{/* inner spacer: full scrollable size */}
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${item.start}px)`,
}}
>
{rows[item.index]}
</div>
))}
</div>
</div>
);
}Fixed sizes
If every row is the same known height, drop data-index and measureElement
and return that height from estimateSize — the layout is exact with zero
measurement work.
Horizontal lists
Pass horizontal: true and swap the axis in your styles (width from
getTotalSize(), transform: translateX(${item.start}px)).
Scroll to an item
virtualizer.scrollToIndex(5000, { align: "center", behavior: "smooth" });
virtualizer.scrollToOffset(0, { behavior: "smooth" }); // back to topWindow / document scrolling
When the whole page scrolls and the list starts partway down, point
getScrollElement at the scrolling element and set scrollMargin to the list's
distance from the top of the scrollable content.
Sticky / pinned rows
Pass stickyIndices to keep certain rows mounted even when they scroll out of
view — perfect for section headers. They are always included in
getVirtualItems() (in ascending order, never duplicated), so you can render
them with position: sticky while every other row stays windowed.
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
stickyIndices: headerIndices, // e.g. [0, 12, 30]
});React-free math core
All the geometry is exported as pure functions with no React and no DOM, so you can compute layouts in a worker or on the server, or unit-test them directly:
import { calculateRange, scrollToIndexOffset } from "@lacspace/virtual";
const { virtualItems, totalSize, range } = calculateRange({
count: 100_000,
estimateSize: (i) => (i % 2 ? 32 : 48), // variable sizes
scrollOffset: 12_000, // scrollTop (or scrollLeft when horizontal)
containerSize: 600, // clientHeight (or clientWidth when horizontal)
overscan: 5,
stickyIndices: [0],
measured: new Map([[3, 80]]), // measured-size overrides
});
// Where to scroll to reveal an item (start | center | end | auto):
const offset = scrollToIndexOffset(calculateRange(/* … */).items, 5000, {
align: "center",
containerSize: 600,
});The useVirtualizer hook is a thin state/effects wrapper around this core, so it
and the exported functions always agree.
API
useVirtualizer(options): Virtualizer
VirtualizerOptions
| Option | Type | Default | Description |
| ------------------ | -------------------------------------- | ------- | ------------------------------------------------------------------ |
| count | number | — | Total number of items. |
| getScrollElement | () => HTMLElement \| null | — | Returns the scroll container (re-resolved each render). |
| estimateSize | (index: number) => number | — | Estimated (or fixed) size in px along the scroll axis. |
| overscan | number | 5 | Extra items rendered on each side of the viewport. |
| horizontal | boolean | false | Lay out along the x-axis (left/width, scrollLeft). |
| gap | number | 0 | Gap in px between items. |
| getItemKey | (index: number) => number \| string | index | Stable key per item. |
| paddingStart | number | 0 | Leading padding before the first item (counts toward total size). |
| scrollMargin | number | 0 | Offset from scroll-content start to the list start. |
| stickyIndices | number[] | [] | Indices always kept in getVirtualItems() even when out of view. |
Virtualizer
| Member | Type | Description |
| ------------------ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| getVirtualItems()| () => VirtualItem[] | Items currently in view, plus overscan. |
| getTotalSize() | () => number | Full list size (px) for the inner spacer. |
| scrollToIndex() | (index, { align?: "start"\|"center"\|"end"\|"auto"; behavior? }) => void | Scroll an item into view. |
| scrollToOffset() | (offset, { behavior? }) => void | Scroll to an absolute px offset. |
| measureElement() | (el: HTMLElement \| null) => void | Ref callback for dynamic sizing (needs data-index on the node). |
| range | { startIndex: number; endIndex: number } \| null | The rendered index range (incl. overscan). |
| options | VirtualizerOptions | The options passed in. |
VirtualItem
interface VirtualItem {
index: number; // position in the source list
start: number; // leading edge offset (px), relative to the inner container
size: number; // measured or estimated size (px)
end: number; // start + size
key: number | string;
}Pure core (React-free)
These are exported alongside the hook and use no React and no DOM — ideal for tests, workers or SSR.
| Function | Description |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| calculateRange(input): CalculateRangeResult | Whole pipeline: { items, totalSize, range, virtualItems, stickyItems } from scroll + size.|
| buildMeasurements(count, estimateSize, measured, paddingStart, gap, getItemKey?) | The cumulative offset/size table (VirtualItem[]). |
| computeRange(items, scrollOffset, viewport, overscan, scrollMargin) | The visible index range (incl. overscan), or null. |
| findStartIndex(items, offset) | Binary search for the first item at a scroll offset. |
| getTotalSize(items) | Total list size (px). |
| scrollToIndexOffset(items, index, { align, containerSize, scrollMargin?, currentOffset? }) | Target scroll offset to reveal an item, or null. |
| augmentRangeWithSticky(range, stickyIndices, count) | Range indices unioned with sticky indices (ascending, de-duped). |
| normalizeStickyIndices(stickyIndices, count) | Sort/de-dupe/clamp a sticky-index list. |
calculateRange accepts { count, estimateSize, scrollOffset, containerSize, overscan?, paddingStart?, gap?, scrollMargin?, measured?, stickyIndices?, getItemKey? }. The math is axis-agnostic, so horizontal lists pass scrollLeft/clientWidth.
Why it's tiny
- No components, no CSS. It returns numbers; you render whatever you like.
- Zero dependencies. Pure React hooks and the platform (
ResizeObserver,getBoundingClientRect,scrollTo). - Cheap math. A cumulative-offset table is built once per data change and the visible window is found with a binary search — item lookups don't scan the list.
- SSR-safe. No DOM access during render; measurement happens only in effects. On the server you get an estimated layout, so markup matches on hydration.
- No render loops. State updates only fire when a value actually changes, and measured sizes are cached, so re-measuring a stable row is a no-op.
Licensing
Free under the Lacspace Free Licence — permissive freedoms. Use it in personal and commercial projects at no cost; just keep the notice. See the Lacspace Licence Centre.
The Lacspace Developer Platform
@lacspace/virtual is part of 80+ zero-dependency, isomorphic TypeScript packages — one standard library for the modern web. Explore the ecosystem:
- 📦 This package, documented — https://developer.lacspace.com/packages/virtual
- 🗂️ All 80+ packages — https://developer.lacspace.com/packages
- 🧭 Developer handbook — guides & runnable recipes — https://developer.lacspace.com/handbook
- 🧪 Live playground — run any package in your browser — https://developer.lacspace.com/playground
- 🖥️ Finished app templates — https://templates.lacspace.com
- 🚀 Scaffold a full app —
npm create lacspace-app@latest
Free under the Lacspace Free Licence — a permissive, free-to-use licence.
