Vanilla-JS virtual list with lazy loading and initial skeletons.
Clusterize-Lazy
lets you render millions of rows in a scrollable
container while downloading data only for what the user can actually
see. Its goal is an easy, framework-agnostic API that just works in
any modern browser - no build step required.
Small footprint, great DX - powered by @tanstack/virtual-core
-
Single dependency - relies on the rock-solid engine
@tanstack/virtual-core
(thanks Tanner & the TanStack team!) - Dynamic row height - actual DOM sizes are measured automatically
- Lazy loading + skeletons - smooth UX even on shaky connections
-
Typed from the ground up - shipped
.d.ts
works in ESM browsers and legacy browsers (with polyfills) - Batteries included - debug logging, auto cache eviction, progress callback
Test it instantly (no transpiler):
https://joobypm.github.io/clusterize-lazy/examples/quotes.html
Source lives in
docs/examples/
pnpm add clusterize-lazy
import Clusterize from 'clusterize-lazy';
const cluster = Clusterize({...});
<script src="https://unpkg.com/clusterize-lazy/dist/index.iife.js"></script>
<script>
const cluster = Clusterize.default({...});
</script>
<div id="scroll" style="height: 320px; overflow: auto">
<div id="content"></div>
</div>
<script type="module">
import Clusterize from '/dist/index.esm.js';
function fetchRows(offset, size = 40) {
return fetch(`/api/items?skip=${offset}&limit=${size}`).then((r) => r.json());
}
const cluster = Clusterize({
rowHeight: 32,
scrollElem: document.getElementById('scroll'),
contentElem: document.getElementById('content'),
fetchOnInit: async () => {
const rows = await fetchRows(0);
return { totalRows: 50_000, rows };
},
fetchOnScroll: fetchRows,
renderSkeletonRow: (h, i) => `<div class="skeleton" style="height:${h}px"></div>`,
renderRaw: (i, row) => `<div>${i + 1}. ${row.title}</div>`,
});
</script>
// Enable mutations with buildIndex
const cluster = Clusterize({
rowHeight: 40,
buildIndex: true, // Enable ID-based operations
primaryKey: 'id', // Use 'id' field as primary key
scrollElem: document.getElementById('scroll'),
contentElem: document.getElementById('content'),
fetchOnInit: () => Promise.resolve([
{ id: 1, name: 'Alice', status: 'active' },
{ id: 2, name: 'Bob', status: 'inactive' },
{ id: 3, name: 'Charlie', status: 'active' }
]),
fetchOnScroll: () => Promise.resolve([]),
renderSkeletonRow: (h) => `<div style="height:${h}px">Loading...</div>`,
renderRaw: (i, row) => `<div>${row.name} (${row.status})</div>`
});
// Add new rows
cluster.insert([{ id: 4, name: 'David', status: 'active' }], 1);
// Update existing row by ID
cluster.update([{ id: 2, data: { id: 2, name: 'Bob', status: 'active' } }]);
// Remove rows by ID
cluster.remove([1, 3]);
Option / method | Type / default | Purpose |
---|---|---|
required | ||
rowHeight |
number |
Fixed row estimate (px) |
fetchOnInit() |
() ⇒ Promise<Row[] | { totalRows, rows }> |
First data batch or rows + total count |
fetchOnScroll(offset) |
(number) ⇒ Promise<Row[]> |
Fetches when a gap becomes visible |
renderSkeletonRow(height,index) |
(number,number) ⇒ string |
Placeholder HTML |
optional | ||
renderRaw(index,data) |
(number,Row) ⇒ string · undefined
|
Row renderer for object data |
buffer |
5 |
Rows rendered above/under viewport |
prefetchRows |
buffer |
Rows fetched ahead of viewport |
debounceMs |
120 |
Debounce between scroll & fetch |
cacheTTL |
300 000 |
Milliseconds before a cached row is stale |
autoEvict |
false |
Drop stale rows automatically |
showInitSkeletons |
true |
Paint skeletons immediately before first fetch |
debug |
false |
Console debug output |
scrollingProgress(cb) |
(firstVisible:number) ⇒ void |
Fires on every render |
buildIndex |
false |
Build ID-to-index map for mutations |
primaryKey |
'id' |
Property name for primary key |
methods | ||
refresh() |
void |
Force re-render |
scrollToRow(idx, smooth?) |
void ( true = smooth) |
Programmatic scroll |
getLoadedCount() |
number |
How many rows are cached |
destroy() |
void |
Tear down listeners & cache |
insert(rows, at?) |
void |
Insert rows at position (default: 0) |
update(patches) |
void |
Update rows by ID or index |
remove(keys) |
void |
Remove rows by ID or index |
_dump() |
{ cache, index } |
Debug helper for internal state |
See docs/API.md for the full contract.
git clone https://github.com/JoobyPM/clusterize-lazy.git
pnpm i
pnpm test # vitest + jsdom
pnpm build # tsup + esbuild
Formatting & linting are handled by Deno (deno fmt
, deno lint
).
Please follow Conventional Commits; releases are automated.
Huge shout-out to [@tanstack/virtual-core] - Clusterize-Lazy is basically a thin, opinionated shell around this fantastic engine. If you need a React / Vue / Solid binding or more power, use the TanStack package directly and consider sponsoring the project.
MIT © 2025 JoobyPM