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.
hcg-virtual-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.
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<div id="list1" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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.
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<div id="list2" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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.
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<div id="list3" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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.
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<div id="list4" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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.
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<div id="list5" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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().
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<input type="text" id="chat-input" placeholder="Type a message…">
<div id="list6" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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.
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<div id="list7" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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 |
|---|
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<div class="vs-table-wrap">
<div class="vs-table-inner">
<div id="s8-header">
<table>
<thead>
<tr>
<th class="col-id">ID</th>
<th>Name</th>
<th>Email</th>
<th class="col-status">Status</th>
</tr>
</thead>
</table>
</div>
<div id="list8" style="height:360px;overflow-y:auto"></div>
</div>
</div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
const vs = new HCGVirtualScroll(rows, {
container: '#list8',
itemHeight: 44,
keyField: 'id',
renderItem(item) {
return `<table><tr>
<td class="col-id">${item.id}</td>
<td>${item.name}</td>
<td>${item.email}</td>
<td class="col-status">${item.status}</td>
</tr></table>`;
},
onResize() { syncHeader(); },
});
function syncHeader() {
const list = document.getElementById('list8');
const header = document.getElementById('s8-header');
header.style.paddingRight = (list.offsetWidth - list.clientWidth) + 'px';
}
syncHeader();
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.
HTML
<link rel="stylesheet" href="hcg-virtual-scroll.css">
<input type="text" id="search-input" placeholder="Search by name or email…">
<div id="list9" style="height:360px;overflow-y:auto"></div>
<script src="hcg-virtual-scroll.js"></script>
JavaScript
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);
});