Interactive examples of hcg-virtual-scroll, a high-performance vanilla JavaScript virtual scrolling library that renders 1,000,000+ list items smoothly with no framework and no dependencies. Scroll any demo below and only the visible rows stay in the DOM - open your browser DevTools to watch the element count stay small while you scroll.
The most common virtual scrolling setup. Every row shares the same height, so the library calculates positions instantly. This example holds 10,000 rows but keeps only the visible ones in the DOM, so scrolling stays smooth no matter how large the list grows.
const vs = new HCGVirtualScroll(data, {
container: '#list1',
itemHeight: 50,
bufferSize: 3,
adaptiveOverscan: true,
renderItem(item, i) {
return `<div class="row">
<span>#${i}</span>
<span>${item.name}</span>
<span class="badge">${item.badge}</span>
</div>`;
},
onScroll(scrollTop, { start, end }) {
console.log('scroll:', scrollTop, 'visible:', start, '-', end);
},
});
When rows vary in height - cards, multi-line text, or expandable items - pass a function to itemHeight. Each item's height is pre-calculated, so the library positions variable-height rows correctly without measuring the DOM during scroll.
// each item has a pre-calculated height property
const data = items.map(item => ({
...item,
height: item.body.length > 80 ? 96 : 44,
}));
const vs = new HCGVirtualScroll(data, {
container: '#list2',
itemHeight: item => item.height, // function per item
estimatedItemHeight: 60, // fallback
bufferSize: 4,
renderItem(item) {
return `<div style="height:${item.height}px">
<strong>${item.title}</strong>
<p>${item.body}</p>
</div>`;
},
});
Set keyField to a unique property and the library reuses existing DOM nodes instead of recreating them on every scroll. This preserves checkbox state, focus, and input values. Tick a few boxes, scroll away, then scroll back - the state is kept.
const data = makeItems(1000); // each item: { id, name, ... }
const vs = new HCGVirtualScroll(data, {
container: '#list3',
itemHeight: 72,
keyField: 'id', // enables DOM recycling - reuses existing elements
renderItem(item) {
// item.checked stored on data object - survives scrolling out of buffer
return `<div class="row">
<input type="checkbox" ${item.checked ? 'checked' : ''} class="item-cb" />
<span>${item.name}</span>
</div>`;
},
});
// event delegation - update item.checked directly on the data object by index
document.getElementById('list3').addEventListener('change', function (e) {
if (!e.target.classList.contains('item-cb')) return;
const row = e.target.closest('[data-vs-key]');
if (!row) return;
const id = parseInt(row.dataset.vsKey, 10);
data[id].checked = e.target.checked; // write state back to data
});
// uncheck all - reset data then refresh DOM
function uncheckAll() {
data.forEach(item => item.checked = false);
vs.refresh();
}
Update any option - row height, render function, or buffer size - without destroying and recreating the instance. The list re-renders instantly with the new settings, making it easy to switch layouts or view modes on the fly.
const vs = new HCGVirtualScroll(data, {
container: '#list4',
itemHeight: 60,
renderItem: renderDefault,
});
// change item height at runtime - no re-create needed
vs.updateConfig({ itemHeight: 36 });
vs.updateConfig({ itemHeight: 90 });
// swap the render function completely
vs.updateConfig({ renderItem: renderCompact });
// change multiple options at once
vs.updateConfig({ bufferSize: 8, adaptiveOverscan: false });
Use the onReachEnd callback to fetch and append more items as the user nears the bottom - the standard pattern for feeds, search results, and product listings. This demo simulates a 600 ms network request and loads 30 rows per batch.
let loading = false;
const vs = new HCGVirtualScroll(initialData, {
container: '#list5',
itemHeight: 56,
reachEndThreshold: 8, // fire when 8 items from the end
renderItem: (item, i) => `<div class="row">#${i} ${item.name}</div>`,
onReachEnd({ total }) {
if (loading) return; // prevent duplicate requests
loading = true;
fetchNextPage(total).then(batch => {
vs.append(batch); // append preserves scroll position
loading = false;
});
},
});
See exactly when the onReachEnd callback fires. Adjust the threshold and watch the event log record each trigger as you scroll toward the end - useful for tuning when infinite-scroll loading should begin.
const vs = new HCGVirtualScroll(data, {
container: '#list7',
itemHeight: 50,
reachEndThreshold: 5, // fires when last 5 items become visible
renderItem: (item, i) => `<div class="row">${i} - ${item.name}</div>`,
onReachEnd({ start, end, total }) {
// fires once per approach - resets when user scrolls back up
console.log('Reached end', { start, end, total });
},
});
// change threshold at runtime
vs.updateConfig({ reachEndThreshold: 15 });
Set reverse: true to anchor the list to the bottom, just like a chat window or activity feed. New messages appear at the bottom and the view stays pinned, while scrolling to the top loads older history with prepend().
const vs = new HCGVirtualScroll(messages, {
container: '#list6',
itemHeight: msg => msg.height,
estimatedItemHeight: 74,
reverse: true, // anchor to bottom
renderItem(msg) {
const isMe = msg.author === 'me';
return `<div class="chat-row ${isMe ? 'me' : 'bot'}">
<div class="bubble">${msg.text}</div>
</div>`;
},
onReachStart() {
// user scrolled to top - load older history
loadHistory().then(older => vs.prepend(older));
},
});
// send a new message - auto-scrolls if user is at bottom
vs.append([{ id: Date.now(), author: 'me', text: 'Hello!', height: 54 }]);
Render a large data table with a sticky header and aligned columns. Each row is a small table that shares the header's fixed column widths, so the layout stays consistent while only the visible rows are rendered - letting a 100,000-row table scroll smoothly.
| ID | Name | Status |
|---|
<!-- header table sits ABOVE the scroll container -->
<table style="width:100%;table-layout:fixed;">
<thead>
<tr><th style="width:80px">ID</th><th>Name</th><th>Email</th><th style="width:110px">Status</th></tr>
</thead>
</table>
<!-- the scroll container the library mounts on -->
<div id="list8" style="height:400px;overflow-y:auto;"></div>
const vs = new HCGVirtualScroll(rows, {
container: '#list8',
itemHeight: 44,
// each row is a mini-table using the SAME fixed column widths as the header
renderItem(item) {
return `<table style="width:100%;table-layout:fixed;"><tbody><tr>
<td style="width:80px">${item.id}</td>
<td>${item.name}</td>
<td>${item.email}</td>
<td style="width:110px">${item.status}</td>
</tr></tbody></table>`;
},
});
Filter a 50,000-row list as you type. On each keystroke a new filtered array is passed to updateData(), and the list re-renders instantly. When nothing matches, the built-in empty state is shown.
const allItems = makeItems(50000); // the full, unfiltered dataset
const vs = new HCGVirtualScroll(allItems, {
container: '#list9',
itemHeight: 50,
emptyText: 'No matches found',
renderItem: renderRow,
});
// filter a NEW array on every keystroke - never mutate allItems
searchInput.addEventListener('input', function (e) {
const q = e.target.value.toLowerCase();
const filtered = allItems.filter(function (item) {
return item.name.toLowerCase().indexOf(q) > -1
|| item.email.toLowerCase().indexOf(q) > -1;
});
vs.updateData(filtered); // empty result shows the emptyText state
});