DOM-recycling scroll engine for the web.
UITableView / RecyclerView-level performance with zero dependencies.
Single-column lists or multi-column grids — same recycling engine, same API.
The browser was never designed for long lists. Render 10,000 items and you get 10,000 DOM nodes — sluggish scrolling, enormous memory footprints, and wasted layout passes on elements no one can see. The usual "virtual scroll" libraries help, but most are framework-specific, struggle with variable-height content, and break down once images load and heights shift.
blue-grid solves this the same way native mobile platforms have for over a decade: by recycling a small, fixed pool of real DOM nodes and repositioning them as the user scrolls. The total number of DOM nodes in play stays constant — whether your list has 100 items or 100,000.
- Constant DOM count — only items in and around the viewport exist in the DOM.
- 60 fps scrolling —
requestAnimationFrame-gated scroll loop; no layout thrashing. - Variable-height items — heights are measured after render and cached. No need to know them up front.
- Async-safe —
ResizeObserveron every attached node catches image loads, font rendering, and dynamic content changes, then repositions automatically. - Scroll anchoring — position is tracked as
{ index, offset }, not rawscrollTop. When heights change, the viewport stays anchored to the same content. - Finite and infinite lists — pass a
countfor bounded data, or useprefetchfor unbounded feeds with placeholder loading states. - Grid layout — pass
item_widthto switch to multi-column mode. Columns are derived from the container width and reflow on resize. By default, spacing usesspace-evenlydistribution. Addgap_minto fix the gap and let items stretch beyonditem_widthto fill available space. Same recycling, same anchoring, same performance. - Zero dependencies — vanilla JS, no framework, no virtual DOM, no build step required.
bun add git+https://github.com/bluegate-studio/blue-grid.gitThen import it as an ES module:
import { BlueGrid } from 'blue-grid';Or reference the source directly from a <script type="module"> tag — no bundler needed.
When you have all your data up front:
import { BlueGrid } from 'blue-grid';
const items = [ /* your data array */ ];
const grid = new BlueGrid(document.querySelector('#list'), {
count: items.length,
cell(index, reuse) {
const el = reuse || document.createElement('div');
el.textContent = items[index].text;
return el;
},
});cell(index, reuse) is called whenever the grid needs a DOM node for a given index. If reuse is not null, it's a previously used node — reconfigure it instead of creating a new one. This is where the recycling happens.
When data is fetched on demand:
const data = [];
const grid = new BlueGrid(document.querySelector('#feed'), {
cell(index, reuse) {
const el = reuse || create_item_element();
populate(el, data[index]);
return el;
},
placeholder() {
// Return a DOM node to show while data loads.
// Optional — a shimmer bar is used by default.
const el = document.createElement('div');
el.className = 'skeleton';
return el;
},
prefetch: async (range) => {
const batch = await api.load(range.start, range.end);
data.push(...batch);
grid.set_count(data.length);
},
});When the user scrolls past loaded data, prefetch is called with the range of indices needed. Placeholder nodes fill the gap until you call set_count() with the new total, at which point real content replaces them.
When you want a card grid instead of a single-column list:
const grid = new BlueGrid(document.querySelector('#gallery'), {
count: items.length,
item_width: 240,
gap_min: 12,
cell(index, reuse) {
const el = reuse || create_card_element();
populate(el, items[index]);
return el;
},
});item_width sets the minimum cell width. The number of columns is calculated from the container width and reflows on resize. gap_min fixes the gap at the specified value — items stretch beyond item_width to absorb excess space, preventing oversized margins on narrow viewports. Without gap_min, spacing uses space-evenly distribution and items stay at exactly item_width. Everything else — recycling, scroll anchoring, placeholders, prefetch — works identically.
| Parameter | Type | Required | Description |
|---|---|---|---|
container |
HTMLElement |
✅ | The scrollable container element |
config.cell |
(index, reuse) => HTMLElement |
✅ | Return a DOM node for item at index. reuse is a recycled node or null |
config.count |
number |
— | Total item count (default 0) |
config.placeholder |
() => HTMLElement |
— | Factory for placeholder nodes (default: shimmer bar) |
config.prefetch |
(range) => Promise<void> |
— | Called when items beyond loaded data approach the viewport |
config.item_width |
number |
— | Minimum cell width in pixels. Enables multi-column grid mode |
config.gap_min |
number |
— | Fixed gap in pixels between items and edges. Items stretch beyond item_width to fill space. Requires item_width |
| Method | Description |
|---|---|
set_count(n) |
Update the total item count — triggers a re-render |
reload() |
Re-render all visible items (like reloadData()) |
scroll_to(index) |
Scroll to a specific item by index |
destroy() |
Remove all listeners, detach nodes, clean up |
blue-grid is heavily inspired by the ideas and engineering behind:
- Infinite Scroller — the Chrome team's reference implementation of DOM recycling and scroll anchoring for the web (2015). The techniques described in that post — tombstone placeholders, viewport-only rendering, anchor-based scroll tracking — are the conceptual foundation of this library.
- UITableView — Apple's table view component, which pioneered cell reuse with
dequeueReusableCellover a decade before the web caught up. Thecell(index, reuse)API is a direct nod to this pattern. - RecyclerView — Android's adapter-based view recycler, which proved that a fixed pool of views plus a bind-by-index pattern scales to any dataset size.
With sincere thanks to the authors and developers of all three. Standing on the shoulders of giants.
MIT © Bluegate Studio