clusterize-lazy
Version:
Lightweight virtual list helper for the browser.
170 lines (131 loc) • 7.8 kB
Markdown
Vanilla-JS virtual list with lazy loading and initial skeletons.
<span id="size-badge"></span> 
`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](https://github.com/TanStack/virtual)
<div align="center"><img src="docs/assets/demo.gif" width="600" alt="demo"/></div>
- **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
## Live demo
Test it instantly (no transpiler):\
<https://joobypm.github.io/clusterize-lazy/examples/quotes.html>
> Source lives in `docs/examples/`
## Installation
### pnpm / npm / Yarn
```bash
pnpm add clusterize-lazy
```
```js
import Clusterize from 'clusterize-lazy';
const cluster = Clusterize({...});
```
```html
<script src="https://unpkg.com/clusterize-lazy/dist/index.iife.js"></script>
<script>
const cluster = Clusterize.default({...});
</script>
```
```html
<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>
```
```js
// 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](docs/API.md) for the full contract.
```bash
git clone https://github.com/JoobyPM/clusterize-lazy.git
pnpm i
pnpm test
pnpm build
```
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