Interactive demos of hcg-virtual-scroll, a high-performance zero-dependency JavaScript
virtual scrolling library. Scroll 1,000,000+ rows smoothly with only the visible rows in the DOM -
try fixed and dynamic heights, DOM recycling, infinite scroll, chat mode, tables, and live search.
Open DevTools and watch the DOM node count stay small while you scroll.
Full documentation
The most common virtual scrolling setup. Every row shares the same height, so the library calculates positions instantly. Only visible rows stay in the DOM.
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, pass a function to itemHeight. Each item's height is pre-calculated so variable-height rows position correctly without measuring the DOM during scroll.
const data = items.map(item => ({
...item,
height: item.body.length > 80 ? 96 : 44,
}));
const vs = new HCGVirtualScroll(data, {
container: '#list2',
itemHeight: item => item.height,
estimatedItemHeight: 60,
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. Checkbox state survives scrolling away and back.
const vs = new HCGVirtualScroll(data, {
container: '#list3',
itemHeight: 72,
keyField: 'id',
renderItem(item) {
return `<div class="row">
<input type="checkbox" ${item.checked ? 'checked' : ''} class="item-cb" />
<span>${item.name}</span>
</div>`;
},
});
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;
});
Update row height, render function, or buffer size without destroying the instance. The list re-renders instantly with the new settings.
vs.updateConfig({ itemHeight: 36 });
vs.updateConfig({ renderItem: renderCompact });
vs.updateConfig({ bufferSize: 8, adaptiveOverscan: false });
Use onReachEnd to fetch and append more items as the user nears the bottom. This demo simulates a 600 ms network request and loads 30 rows per batch.
const vs = new HCGVirtualScroll(initialData, {
container: '#list5',
itemHeight: 56,
reachEndThreshold: 8,
renderItem: (item, i) => `<div class="row">#${i} ${item.name}</div>`,
onReachEnd({ total }) {
if (loading) return;
loading = true;
fetchNextPage(total).then(batch => {
vs.append(batch);
loading = false;
});
},
});
Set reverse: true to anchor the list to the bottom like a chat window. New messages appear at the bottom; scrolling to the top loads older history with prepend().
const vs = new HCGVirtualScroll(messages, {
container: '#list6',
itemHeight: msg => msg.height,
estimatedItemHeight: 74,
reverse: true,
renderItem(msg) {
const isMe = msg.author === 'me';
return `<div class="chat-row ${isMe ? 'me' : 'bot'}">
<div class="bubble">${msg.text}</div>
</div>`;
},
onReachStart() {
loadHistory().then(older => vs.prepend(older));
},
});
vs.append([{ id: Date.now(), author: 'me', text: 'Hello!', height: 54 }]);
See exactly when the onReachEnd callback fires. Adjust the threshold and watch the event log record each trigger as you scroll toward the end.
const vs = new HCGVirtualScroll(data, {
container: '#list7',
itemHeight: 50,
reachEndThreshold: 5,
onReachEnd({ start, end, total }) {
console.log('Reached end', { start, end, total });
},
});
vs.updateConfig({ reachEndThreshold: 15 });
Render a large data table with a sticky header and aligned columns. Each row is a mini-table sharing the header's fixed column widths so only visible rows are rendered.
| ID | Name | Status |
|---|
const vs = new HCGVirtualScroll(rows, {
container: '#list8',
itemHeight: 44,
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(). When nothing matches, the built-in empty state is shown.
searchInput.addEventListener('input', function (e) {
const q = e.target.value.toLowerCase();
const filtered = allItems.filter(item =>
item.name.toLowerCase().includes(q) ||
item.email.toLowerCase().includes(q)
);
vs.updateData(filtered);
});