JavaScript Virtual Scrolling Library for Large Lists and Infinite Scroll
This JavaScript hcg-virtual-scroll is a high-performance virtual scrolling library written in pure JavaScript. Instead of rendering every item in your list, it only keeps the visible rows in the DOM at any time. Whether your list has 1,000 items or 1,000,000 items, the page stays fast and the scrollbar behaves naturally.
No framework required. No dependencies. Works with any plain JavaScript array.
Table of Contents
What Is Virtual Scrolling?
Virtual scrolling is a rendering technique used to improve performance when displaying very large lists. A normal list creates one DOM element for every item, so a list of 50,000 rows creates 50,000 elements - slowing the browser down, using large amounts of memory, and making scrolling stutter.
Virtual scrolling solves this by rendering only the rows that are currently visible inside the scroll container. As the user scrolls, the visible content updates dynamically while off-screen items are skipped or recycled. The user sees a normal, full-length scrollbar, but the DOM only ever holds a few dozen elements, keeping memory low and scrolling smooth even on low-end devices.
What Is hcg-virtual-scroll?
hcg-virtual-scroll is a virtual scrolling library written in pure JavaScript that brings this technique to any website with no framework and no dependencies. You give it your data array and a function that describes how one row looks, and it manages everything else - measuring positions, calculating the visible range, recycling DOM nodes, and keeping the scroll position correct.
It works with plain div, ul, ol, and table containers, supports fixed or variable row heights, and scales to 1,000,000 or more items without freezing the page. Because it is plain JavaScript, you can drop it into any project - including ones built with React, Vue, or no framework at all.
Key Features
- Handles 1,000,000+ items without UI freeze
- Fixed or dynamic item heights
- Works with
<div>,<ol>, and<table>containers - DOM recycling with keyField - reuses existing elements on scroll
- Infinite scroll with onReachEnd callback
- Chat and feed mode with reverse anchoring
- Adaptive overscan - automatically increases buffer during fast scrolling
- ResizeObserver support - re-renders when the container size changes
- ARIA list and listitem roles built in
- Hot-swap any option at runtime with updateConfig
- CommonJS and browser global support - no build step needed
Live Demo
Scroll through 10,000 items below. Only the visible rows exist in the DOM at any time, open your browser DevTools and inspect the list while scrolling to see it in action.
Why Use hcg-virtual-scroll?
Use it whenever you need to display a long list or table without slowing down the browser. It is a good fit for data grids, search results, chat windows, activity feeds, product catalogs, log viewers, and any interface that loads large amounts of rows.
- Handles huge lists - renders 1,000,000+ items without the page freezing.
- Low memory usage - only the visible rows exist in the DOM, so memory stays flat no matter how large the dataset grows.
- Smooth scrolling - updates are throttled to one render per animation frame for a steady 60 frames per second.
- No framework, no build step - drop in one script file and it works.
- Flexible - supports fixed or variable row heights, DOM recycling, infinite scroll, and chat-style reverse mode.
- Accessible - adds ARIA list and listitem roles automatically.
- Free and open source - released under the MIT license for personal and commercial use.
How hcg-virtual-scroll Compares to react-window, react-virtualized, and TanStack Virtual
Most popular virtual scrolling libraries are tied to a specific framework. hcg-virtual-scroll takes the opposite approach: it is plain JavaScript, so it works in React, Vue, Svelte, or no framework at all, with the same API everywhere.
| Library | Framework | Dependencies | Plain JS / no build | Table support | Reverse / chat mode |
|---|---|---|---|---|---|
| hcg-virtual-scroll | Any / none | Zero | Yes | Yes | Yes (built-in) |
| react-window | React only | React | No | Limited | No |
| react-virtualized | React only | React | No | Yes | No |
| TanStack Virtual | React, Vue, Solid, Svelte, and more | Framework adapter | No | Headless | Manual |
How to Install
Direct Script Include
Download the files and reference them in your HTML. This is the simplest way to get started - no build tools needed.
<link rel="stylesheet" href="hcg-virtual-scroll.css" />
<script src="hcg-virtual-scroll.js"></script> After the script loads, the global HCGVirtualScroll class is available on the window object.
NPM Usage
Install the package with npm or yarn.
npm install hcg-virtual-scroll Then import it into your project. The library supports CommonJS and ES module style imports.
// CommonJS
const HCGVirtualScroll = require('hcg-virtual-scroll');
// ES Module
import HCGVirtualScroll from 'hcg-virtual-scroll';
import 'hcg-virtual-scroll/hcg-virtual-scroll.css'; Remember to also include the stylesheet from the package in your build or HTML.
CDN Usage
Load the library directly from a CDN without installing anything.
<!-- Stylesheet -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/hcg-virtual-scroll/hcg-virtual-scroll.css" />
<!-- Script -->
<script src="https://cdn.jsdelivr.net/npm/hcg-virtual-scroll/hcg-virtual-scroll.js"></script> An unpkg link works the same way.
<link rel="stylesheet" href="https://unpkg.com/hcg-virtual-scroll/hcg-virtual-scroll.css" />
<script src="https://unpkg.com/hcg-virtual-scroll/hcg-virtual-scroll.js"></script> Quick Start
Create a container element with a fixed height, then initialize the library with your data array and a renderItem function.
<div id="myList" class="hcg-vs-parent" style="height: 400px;"></div> Initialise:
// sample data - 10,000 items
const data = [];
for (let i = 0; i < 10000; i++) {
data.push({ id: i, name: 'Item ' + i });
}
// initialize virtual scroll
const vs = new HCGVirtualScroll(data, {
container: '#myList',
itemHeight: 50,
renderItem: function(item, index) {
return '<div class="row">#' + index + ' - ' + item.name + '</div>';
}
}); Important: Always use new HCGVirtualScroll(...). Calling without new throws a TypeError
What is renderItem?
renderItem is a function you write that controls how each row looks on screen. It receives two arguments, item, which is one object from your data array, and index, which is the position number. Inside the function, you access your data fields directly using item.yourFieldName and return them as an HTML string. Every visible row in the list is built by calling this function once per item.
renderItem: function(item, index) {
return `<div class="row"> ${index} - ${item.name} - ${item.email} </div>`;
} hcg-virtual-scroll is released under the MIT License - free for personal and commercial use.
Working With Your Data
HCGVirtualScroll accepts any plain JavaScript array. There are no required field names, no special format, and no conversion needed. Whatever your data looks like, just pass it in and access the fields inside renderItem.
Any array works
Array of objects (most common):
const users = [
{ id: 1, name: 'Malik', email: 'Malik@example.com', role: 'Admin' },
{ id: 2, name: 'Anvar', email: 'Anvar@example.com', role: 'Member' },
{ id: 3, name: 'Jhon', email: 'Jhon@example.com', role: 'Member' },
// ... hundreds or thousands more
];
const vs = new HCGVirtualScroll(users, {
container: '#userList',
itemHeight: 56,
emptyText: 'No results found',
renderItem(item, index) {
return `<div class="row">
<span>${index + 1}</span>
<span>${item.name}</span>
<span>${item.email}</span>
<span>${item.role}</span>
</div>`;
},
}); Array of strings:
const countries = ['Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola'];
const vs = new HCGVirtualScroll(countries, {
container: '#countryList',
itemHeight: 44,
emptyText: 'No results found',
renderItem(item, index) {
return `<div class="row">${index + 1}. ${item}</div>`;
},
}); Array of numbers:
const prices = [9.99, 14.99, 4.50, 29.99, 1.00];
const vs = new HCGVirtualScroll(prices, {
container: '#priceList',
itemHeight: 44,
emptyText: 'No results found',
renderItem(item, index) {
return `<div class="row">#${index} - $${item.toFixed(2)}</div>`;
},
}); Loading Data from an API
Fetch your data first, then create the instance. Or create an empty instance immediately and call updateData when the data arrives, useful for showing a loading state.
// Create empty, fill when ready
var vs = new HCGVirtualScroll([], {
container: '#userList',
itemHeight: 56,
emptyText: 'No results found',
loadingHTML: `<div class="my-spinner">Loading...</div>`,
renderItem: function (user) {
return '<div class="row">' + user.name + ' - ' + user.email + '</div>';
},
});
vs.showLoading(); // show loading, block render
fetch('https://jsonplaceholder.typicode.com/users')
.then(function (res) { return res.json(); })
.then(function (users) {
vs.updateData(users); // sets the data internally - render still blocked
vs.hideLoading(); // unblocks render, shows data
}); How showLoading and hideLoading Work Together
While showLoading() is active, all rendering is paused. Scroll events, resize events, and any internal render calls are blocked until hideLoading() is called. This prevents blank or incomplete content from flashing on screen while data is still being fetched.
updateData() can be called while loading is still active, it stores the new data internally even though the render is paused. When hideLoading() is called, the list renders immediately with the correct data already in place.
Always call updateData() before hideLoading(). Calling hideLoading() first causes the empty state to appear for a brief moment before the data renders.
Correct order:
vs.showLoading();
fetch('/api/items')
.then(res => res.json())
.then(data => {
vs.updateData(data); // store data first
vs.hideLoading(); // then reveal
}); Wrong order. causes empty state flicker:
vs.hideLoading(); // renders with no data - empty state appears
vs.updateData(data); // renders again with real data Update the list when your data changes:
// User applies a filter - pass the new filtered array
function applyFilter(keyword) {
const filtered = allItems.filter(item =>
item.name.toLowerCase().includes(keyword.toLowerCase())
);
vs.updateData(filtered);
}
// User sorts - pass the sorted array
function sortByName() {
const sorted = [...allItems].sort((a, b) => a.name.localeCompare(b.name));
vs.updateData(sorted);
} Note: Always filter and sort your array before passing it to updateData(). Never mutate the array directly after passing it in - always create a new array and call updateData().
Supported Container Types
Usage With a DIV Container
The simplest and most common setup. Pass any div with a fixed height as the container. Each renderItem call returns a div row.
<div id="myList" style="height: 500px;"></div> const data = Array.from({ length: 50000 }, (_, i) => ({
id: i,
name: `User ${i}`,
role: i % 3 === 0 ? 'Admin' : 'Member',
}));
const vs = new HCGVirtualScroll(data, {
container: '#divList',
itemHeight: 56,
bufferSize: 4,
renderItem(item, index) {
return `<div class="row">
<span style="width:60px;color:#999">#${index}</span>
<span style="flex:1">${item.name}</span>
<span style="color:#2563eb">${item.role}</span>
</div>`;
},
onScroll(scrollTop, { start, end }) {
console.log(`Scroll: ${scrollTop}px | Visible: ${start}-${end}`);
},
}); Usage With a UL or OL Container
Pass a ul or ol element as the container and return a li element from renderItem. Set overflow-y: auto and a fixed height on the list element itself, or wrap it in a div and pass the wrapper.
<div style="height: 500px; overflow: hidden;">
<ul id="ulList" style="height: 100%; list-style: none; padding: 0; margin: 0;"></ul>
</div>
<!-- <ol id="olList" style="height:500px;overflow-y:auto;list-style:none;padding:0;margin:0;"></ol> -->
Tip: The scroll container must be the element you pass - wrap <ul> in a <div> if needed, or pass the <ul> directly and set overflow-y: auto on it via CSS.
const fruits = Array.from({ length: 10000 }, (_, i) => ({
id: i,
label: `Fruit item #${i}`,
color: ['#f87171','#60a5fa','#34d399','#fbbf24'][i % 4],
}));
const vs = new HCGVirtualScroll(fruits, {
container: '#ulList',
itemHeight: 52,
ariaLabel: 'Fruit list',
renderItem(item) {
return `<li class="row list-item">
<span style="width:12px;height:12px;border-radius:50%;background:${item.color};margin-right:12px;flex-shrink:0"></span>
${item.label}
</li>`;
},
}); Usage With a TABLE Container
For table layouts, the scroll container must be a div wrapper around the table - not the table or tbody element itself. Place a sticky thead inside the table, then render the virtual rows into a sibling div below it using table-layout: fixed to keep columns aligned.
Why not one big
<tbody>? The library positions rows with an internal spacer and a transform, which requires plain block elements that are not valid inside a<table>or <tbody>. Rendering each row as its own small table keeps valid markup, aligns columns throughtable-layout: fixed, and supports the full 1,000,000+ row range. If you do not need native table behavior, a div or CSS grid layout is even simpler.
<!-- header row - same fixed column widths as the data rows -->
<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="tableScroll" style="height:400px;overflow-y:auto;"></div> JavaScript - each row returns a mini table using the same table-layout: fixed widths:
const users = Array.from({ length: 100000 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
status: i % 4 === 0 ? 'Inactive' : 'Active',
}));
const vs = new HCGVirtualScroll(users, {
container: '#tableScroll',
itemHeight: 44,
renderItem(item) {
const color = item.status === 'Active' ? '#16a34a' : '#dc2626';
return `<table style="width:100%;border-collapse:collapse;table-layout:fixed;">
<tbody>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="width:80px;padding:10px 14px;color:#999">${item.id}</td>
<td style="padding:10px 14px;font-weight:500">${item.name}</td>
<td style="padding:10px 14px;color:#555">${item.email}</td>
<td style="width:110px;padding:10px 14px;color:${color};font-weight:600">${item.status}</td>
</tr>
</tbody>
</table>`;
},
}); Column alignment: Use table-layout: fixed with matching width values on both the header <th> cells and each row's <td> cells. That is what keeps every column lined up with the header as you scroll.
Options
Required Options
Both of the following options are required. The instance will throw an error if either is missing.
| Option | Type | Description |
|---|---|---|
container | string or Element | CSS selector ('#id', '.class'), element ID string, or a direct DOM reference |
renderItem | Function | (item, index) => HTML string or HTMLElement - called for each visible item |
Optional Options
| Option | Type | Default | Description |
|---|---|---|---|
itemHeight | number or Function | 65 | Fixed height in px, or (item, index) => number for dynamic heights |
estimatedItemHeight | number | same as itemHeight | Fallback height when itemHeight function returns falsy |
bufferSize | number | 3 | Extra items rendered above and below the visible area |
containerHeight | number | - | Sets container.style.height in px - useful when height is not set in CSS |
maxHeight | number | 10 000 000 | Maximum spacer height in px - prevents browser limits on huge lists |
keyField | string | - | Item property name used as unique key - enables DOM recycling |
adaptiveOverscan | boolean | true | Doubles/quadruples buffer automatically during fast scrolling |
reverse | boolean | false | Anchors to bottom - for chat, feeds, and activity logs |
ariaLabel | string | - | Sets aria-label on the inner list element |
reachEndThreshold | number | 5 | How many items from the end/start trigger reach callbacks |
emptyText | string | - | Plain text shown when the list has no items |
emptyHTML | string | - | Custom HTML shown when the list has no items - takes priority over emptyText |
loadingText | string | 'Loading...' | Text shown while loading state is active |
loadingHTML | string | - | Custom HTML shown while loading state is active - takes priority over loadingText |
Loading & Empty State
| Method | Description |
|---|---|
showLoading() | Show the loading state and pause all scroll rendering |
hideLoading() | Hide the loading state and resume normal rendering |
isLoading() | Returns true if the loading state is currently active |
Config & Lifecycle
| Method | Description |
|---|---|
updateConfig(options) | Hot-update any option without re-creating the instance. Accepts the same keys as the constructor |
refresh() | Force a full re-render without changing data |
getVisibleRange() | Returns { start, end } of the currently rendered range |
destroy() | Remove all event listeners and clear the generated DOM |
Callbacks
All callbacks are optional. Pass them as properties of the options object when creating the instance, or add them later with updateConfig.
onScroll(scrollTop, range)
Fires on every scroll event, throttled to one call per animation frame.
onScroll: function(scrollTop, range) {
console.log('Scrolled to ' + scrollTop + 'px');
console.log('Visible: ' + range.start + ' to ' + range.end);
} | Parameter | Type | Description |
|---|---|---|
scrollTop | number | Current scroll position in pixels |
range.start | number | First rendered item index |
range.end | number | Last rendered item index |
onVisibleRangeChange({ start, end })
Fires only when the rendered range of items actually changes, not on every scroll pixel. Use this to react to which items are on screen without processing every scroll event.
onVisibleRangeChange: function(range) {
console.log('Now showing items ' + range.start + ' to ' + range.end);
} onRender(startIndex, endIndex)
Fires after every DOM update, immediately after new items are written to the page.
onRender: function(startIndex, endIndex) {
console.log('DOM updated: items ' + startIndex + ' to ' + endIndex);
} onReachEnd({ start, end, total }) / onLoadMore (alias)
Fires once when the user scrolls near the bottom of the list. Resets automatically when the user scrolls back up past the threshold. Use for infinite loading. The alias onLoadMore works identically.
| Parameter | Description |
|---|---|
start | index of the first rendered item |
end | index of the last rendered item |
total | total number of items currently in the list |
onReachEnd: function(info) {
fetchNextPage(info.total).then(function(batch) {
vs.append(batch);
});
} onReachStart({ start, end, total })
Fires once when the user scrolls near the top of the list. Use for loading older history in reverse-mode (chat) lists.
onReachStart: function(info) {
loadHistory().then(function(older) {
vs.prepend(older);
});
} onResize({ width, height })
Fires when the container element is resized (powered by ResizeObserver). The list re-renders automatically - this callback is for your own side effects.
onResize: function(size) {
console.log('Container is now ' + size.width + ' x ' + size.height + ' px');
} Public Methods
Data
| Method | Description |
|---|---|
updateData(newData, keepScroll?) | Replace the entire data array. Pass true as second argument to preserve scroll position |
updateItems(newData) | Alias for updateData(newData) - always resets scroll to top |
append(items) | Add items to the end. Scroll position is preserved |
prepend(items) | Add items to the start. Scroll adjusts automatically to prevent visual jump |
clear() | Remove all items and reset all internal state |
getData() | Returns the current data array |
Scroll
| Method | Description |
|---|---|
scrollTo(index, smooth?) | Scroll so item at index appears at the viewport top. Pass true for smooth scroll |
scrollToTop(smooth?) | Scroll to position 0 |
scrollToBottom(smooth?) | Scroll to the last item |
getScrollPosition() | Returns current scrollTop in px |
Common Use Cases
Infinite Scroll Feed
Load more content automatically as the user nears the bottom - ideal for social feeds, search results, and product listings.
let loading = false;
const vs = new HCGVirtualScroll(firstPage, {
container: '#feed',
itemHeight: 72,
reachEndThreshold: 8,
renderItem: renderPost,
onReachEnd: function () {
if (loading) return;
loading = true;
fetchNextPage().then(function (batch) {
vs.append(batch);
loading = false;
});
}
}); Chat / Messaging Window
Anchor the list to the bottom so the newest message is always in view, and load older history when the user scrolls up.
const vs = new HCGVirtualScroll(messages, {
container: '#chat',
itemHeight: function (msg) { return msg.height; },
estimatedItemHeight: 60,
reverse: true,
renderItem: renderMessage,
onReachStart: function () {
loadHistory().then(function (older) {
vs.prepend(older);
});
}
});
// send a new message - auto-scrolls if user is at the bottom
vs.append([{ id: Date.now(), author: 'me', text: 'Hello!', height: 54 }]); Live Search Results
Filter a large array as the user types and pass the new array to updateData. Always create a new array - never mutate the original.
searchInput.addEventListener('input', function (e) {
const q = e.target.value.toLowerCase();
const filtered = allItems.filter(function (item) {
return item.name.toLowerCase().indexOf(q) > -1;
});
vs.updateData(filtered);
}); Selectable List With Checkboxes
Store the checked state on each data object so it survives scrolling. Set keyField to enable DOM recycling.
const vs = new HCGVirtualScroll(items, {
container: '#list',
itemHeight: 60,
keyField: 'id',
renderItem: function (item) {
return '<div class="row">'
+ '<input type="checkbox" ' + (item.checked ? 'checked' : '') + ' class="cb" />'
+ '<span>' + item.name + '</span>'
+ '</div>';
}
});
// write state back to the data object on change
document.getElementById('list').addEventListener('change', function (e) {
if (!e.target.classList.contains('cb')) return;
const id = parseInt(e.target.closest('[data-vs-key]').dataset.vsKey, 10);
items[id].checked = e.target.checked;
}); Performance Benchmarks
The whole point of virtual scrolling is that cost stays flat as your list grows. Because only the visible rows plus a small buffer ever exist in the DOM, a list of 1,000 items and a list of 1,000,000 items hold the same number of elements - so memory and frame rate stay constant while a normal list degrades sharply.
The figures below use the default demo config (itemHeight: 50, bufferSize: 3, a ~400px container showing about 8 rows). DOM node counts reflect the live demo; memory and frame rate are representative of simple text rows on a mid-range laptop and will vary with row complexity.
| List size | Normal list (all rows in DOM) | hcg-virtual-scroll | DOM nodes held |
|---|---|---|---|
| 1,000 | ~1,000 nodes, smooth | smooth, ~60 FPS | ~14 |
| 10,000 | ~10,000 nodes, sluggish | smooth, ~60 FPS | ~14 |
| 100,000 | freezes or may crash the tab | smooth, ~60 FPS | ~14 |
| 1,000,000 | tab crashes | smooth, ~60 FPS | ~14 |
Why the node count stays at ~14: the library renders only the visible rows (about 8 for a 400px container at 50px each) plus bufferSize rows above and below (3 + 3 = 6). Increasing the list size adds zero DOM nodes - it only changes the height of the internal spacer that drives the scrollbar.
Render throttling: scroll updates are batched to one render per animation frame (requestAnimationFrame), which is what keeps scrolling at a steady 60 FPS rather than firing a render on every scroll pixel.
See It in Action: Only Visible Rows in the DOM
This recording scrolls a 100,000-row table with the browser DevTools open. As the list scrolls, watch the element count stay at around 14 - the library only ever keeps the visible rows plus a small buffer in the DOM, no matter how large the list is.
Want to verify on your own machine? Open the live demo, open your browser DevTools, and watch the DOM node counter stay flat as you scroll a 100,000-row table.
Bundle Size
hcg-virtual-scroll is dependency-free and tiny, so it adds almost nothing to your page weight.
| File | Minified | Gzipped |
|---|---|---|
| hcg-virtual-scroll.min.js | 12.7 KB | 3.4 KB |
| hcg-virtual-scroll.min.css | 0.5 KB | ~0.3 KB |
There are no runtime dependencies, so nothing else is pulled into your bundle - the numbers above are the complete cost.
Empty State
When the list has no items, the library renders the empty state inside the content area automatically. This happens on initial render with an empty array, after clear(), and after updateData([]).
const vs = new HCGVirtualScroll([], {
container: '#myList',
itemHeight: 56,
emptyText: 'No results found',
renderItem: item => `<div class="row">${item.name}</div>`,
}); Use emptyHTML for a fully custom layout:
const vs = new HCGVirtualScroll([], {
container: '#myList',
itemHeight: 56,
emptyHTML: `<div class="empty-state">
<img src="empty.svg" alt="No data" />
<p>No items to display</p>
</div>`,
renderItem: item => `<div class="row">${item.name}</div>`,
}); The default empty text is wrapped in .hcg-vs-empty for styling:
.hcg-vs-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 0.95rem;
} If neither emptyText nor emptyHTML is set, the content area is left blank when the list is empty - matching the original behaviour.
Loading State
Call showLoading() before fetching data and hideLoading() when the data is ready. While loading is active, scroll events and re-renders are paused.
const vs = new HCGVirtualScroll([], {
container: '#myList',
itemHeight: 56,
loadingText: 'Fetching data...',
emptyText: 'No results found',
renderItem: item => `<div class="row">${item.title}</div>`,
});
vs.showLoading();
// https://jsonplaceholder.typicode.com/posts
fetch('https://api.example.com/items')
.then(res => res.json())
.then(data => {
vs.updateData(data);
vs.hideLoading();
}); Use loadingHTML for a custom spinner:
const vs = new HCGVirtualScroll([], {
container: '#myList',
itemHeight: 56,
loadingHTML: `<div class="my-spinner">
<div class="spinner-icon"></div>
<p>Loading, please wait...</p>
</div>`,
renderItem: item => `<div class="row">${item.name}</div>`,
}); The default loading text is wrapped in .hcg-vs-loading for styling:
.hcg-vs-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 0.95rem;
} Render priority - loading takes priority over empty:
showLoading() active → loading content is shown
items.length === 0 → empty state is shown
items.length > 0 → normal virtual scroll Check the current state with isLoading():
if (!vs.isLoading()) {
vs.append(newItems);
} Live Config Hot-Swap
Change any option at runtime without destroying and recreating the instance.
const vs = new HCGVirtualScroll(data, {
container: '#hotList',
itemHeight: 56,
renderItem: renderDefault,
});
// Change item height
vs.updateConfig({ itemHeight: 80 });
// Swap render function
vs.updateConfig({ renderItem: renderCompact });
// Change buffer size and disable adaptive overscan
vs.updateConfig({ bufferSize: 8, adaptiveOverscan: false });
// Attach a new callback
vs.updateConfig({ onReachEnd: loadMore });
// Change threshold
vs.updateConfig({ reachEndThreshold: 15 }); Dynamic Item Heights
Pass a function to itemHeight to support rows of different heights.
const posts = Array.from({ length: 5000 }, (_, i) => ({
id: i,
title: `Post #${i}`,
body: i % 3 === 0 ? 'Short post.' : 'A much longer post body that wraps to multiple lines and needs more space to render properly.',
// pre-calculate height so the library can build positions
height: i % 3 === 0 ? 60 : 100,
}));
const vs = new HCGVirtualScroll(posts, {
container: '#dynamicList',
itemHeight: item => item.height, // function returning height per item
estimatedItemHeight: 80, // fallback if function returns 0 / falsy
renderItem(item) {
return `<div style="padding:12px 16px;border-bottom:1px solid #eee;box-sizing:border-box;">
<div style="font-weight:600">${item.title}</div>
<div style="font-size:.85rem;color:#666;margin-top:4px">${item.body}</div>
</div>`;
},
}); Tip: Pre-calculate heights on your data objects. Avoid measuring DOM height during renderItem - that causes layout thrash.
Using With React, Vue, and Svelte
hcg-virtual-scroll is framework-agnostic. It manages its own DOM inside the container element, so in any framework the pattern is the same: create the instance when the element mounts, push new data when it changes, and call destroy() when the component unmounts.
React
Use a ref for the container and create the instance inside useEffect. Return destroy() from the effect so it cleans up on unmount, and call updateData() in a second effect when the data prop changes.
import { useRef, useEffect } from 'react';
import HCGVirtualScroll from 'hcg-virtual-scroll';
import 'hcg-virtual-scroll/hcg-virtual-scroll.css';
export default function VirtualList({
data = [],
itemHeight = 50,
keyField = 'id',
renderItem,
height = 500,
...options // pass any other HCGVirtualScroll option (bufferSize, reverse, onReachEnd, etc.)
}) {
const containerRef = useRef(null);
const vsRef = useRef(null);
const mounted = useRef(false);
// create the instance once on mount, destroy it on unmount
useEffect(() => {
vsRef.current = new HCGVirtualScroll(data, {
container: containerRef.current,
itemHeight,
keyField,
renderItem,
...options,
});
return () => vsRef.current.destroy();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// push new data when the prop changes (skip the first run - already built above)
useEffect(() => {
if (!mounted.current) { mounted.current = true; return; }
if (vsRef.current) vsRef.current.updateData(data);
}, [data]);
return <div ref={containerRef} style={{ height }} />;
} Usage:
import VirtualList from './VirtualList';
const users = Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
name: 'User ' + (i + 1),
}));
export default function App() {
return (
<VirtualList
data={users}
itemHeight={50}
renderItem={(item, i) => `<div class="row">#${i} - ${item.name}</div>`}
/>
);
} Vue 3
Create the instance in onMounted using a template ref, watch the data prop to call updateData(), and destroy it in onBeforeUnmount.
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import HCGVirtualScroll from 'hcg-virtual-scroll';
import 'hcg-virtual-scroll/hcg-virtual-scroll.css';
const props = defineProps({
data: { type: Array, default: () => [] },
itemHeight: { type: [Number, Function], default: 50 },
keyField: { type: String, default: 'id' },
renderItem: { type: Function, required: true },
height: { type: Number, default: 500 },
});
const el = ref(null);
let vs = null;
onMounted(() => {
vs = new HCGVirtualScroll(props.data, {
container: el.value,
itemHeight: props.itemHeight,
keyField: props.keyField,
renderItem: props.renderItem,
});
});
// pass a NEW array to update - the watch fires on reference change
watch(() => props.data, (newData) => { if (vs) vs.updateData(newData); });
onBeforeUnmount(() => { if (vs) vs.destroy(); });
</script>
<template>
<div ref="el" :style="{ height: height + 'px' }"></div>
</template> Usage:
<script setup>
import VirtualList from './VirtualList.vue';
const users = Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
name: 'User ' + (i + 1),
}));
const renderRow = (item, i) => `<div class="row">#${i} - ${item.name}</div>`;
</script>
<template>
<VirtualList :data="users" :item-height="50" :render-item="renderRow" />
</template> Svelte
Create the instance in onMount with bind:this, use a reactive statement to call updateData() when the data changes, and destroy it in onDestroy. The if (vs) guard is important - the reactive statement runs once before onMount, before the instance exists.
<script>
import { onMount, onDestroy } from 'svelte';
import HCGVirtualScroll from 'hcg-virtual-scroll';
import 'hcg-virtual-scroll/hcg-virtual-scroll.css';
export let data = [];
export let itemHeight = 50;
export let keyField = 'id';
export let renderItem;
export let height = 500;
let el, vs, mounted = false;
// create the instance once on mount
onMount(() => {
vs = new HCGVirtualScroll(data, {
container: el,
itemHeight,
keyField,
renderItem,
});
mounted = true;
});
// push new data when it changes (the guard skips the construction-time run)
$: if (vs && mounted) vs.updateData(data);
// remove listeners and the ResizeObserver on unmount
onDestroy(() => vs && vs.destroy());
</script>
<div bind:this={el} style="height: {height}px"></div> usage:
<script>
import VirtualList from './VirtualList.svelte';
const users = Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
name: 'User ' + (i + 1),
}));
const renderRow = (item, i) => `<div class="row">#${i} - ${item.name}</div>`;
</script>
<VirtualList data={users} itemHeight={50} renderItem={renderRow} />
Required CSS for the rows
renderItem returns an HTML string, so style its class yourself. The row heightshould match itemHeight.
.row {
display: flex;
align-items: center;
height: 50px; /* match itemHeight */
padding: 0 16px;
box-sizing: border-box;
border-bottom: 1px solid #eee;
} Things to Know in Any Framework
- Create on mount, destroy on unmount. Always call destroy() in the cleanup hook to remove listeners and the ResizeObserver.
- Do not put framework children inside the container. The library owns that DOM and manages the rows itself.
- renderItem returns HTML strings or DOM elements, not JSX or templates. You cannot place React, Vue, or Svelte components or their event bindings inside a row.
- Handle row clicks with event delegation on the container, reading the data-vs-key attribute to identify the item.
- JavaScript
container.addEventListener('click', (e) => { const row = e.target.closest('[data-vs-key]'); if (row) onRowClick(row.dataset.vsKey); }); - Set keyField so rows recycle correctly when the data updates.
- Update data through updateData() in a watcher or effect keyed on your data - do not mutate the array in place.
- Both components are reusable: they accept data, itemHeight, keyField, renderItem, and height, plus any other library option.
Browser Support
Core virtual scrolling works in all modern browsers and Internet Explorer 11 and above. The ResizeObserver feature (automatic re-render on container resize) is supported in Chrome 64+, Firefox 69+, Safari 13.1+, and Edge 79+. For older environments, a ResizeObserver polyfill covers the only modern API used.
Troubleshooting
The list is blank or rows overlap
This almost always means the itemHeight value does not match the real height of your rows. Set itemHeight to the exact pixel height each row renders at. If your rows have padding or borders, include those in the height, or set box-sizing: border-box on the row element.
The scroll box shows a blank area when there is no data
If you pass an empty array and do not set the emptyText or emptyHTML option, the scroll box renders nothing - it shows a blank area. This is the default behaviour: the library stays silent so it never displays a message you did not ask for.
const vs = new HCGVirtualScroll([], {
container: '#myList',
itemHeight: 50,
emptyText: 'No data available',
renderItem: function (item) {
return '<div class="row">' + item.name + '</div>';
}
}); The empty message is wrapped in an element with the class hcg-vs-empty, which you can style. When you later load data with updateData(), the message is replaced by the rendered rows automatically.
Checkboxes or inputs reset after scrolling
The library reuses DOM nodes only while they remain inside the buffer. When a row scrolls far out of view, its element is removed, and a new one is created when it scrolls back. Store the state on the data object (for example item.checked) and read it back inside renderItem so the state is restored on every render.
Scrolling shows empty gaps during fast scrolling
Increase the bufferSize option to render more rows above and below the viewport. The default is 3. A value of 5 to 8 reduces blank flashes on very fast scrolling. Adaptive overscan is enabled by default and already increases the buffer automatically during fast scrolls.
Variable-height rows jump or misalign
Pre-calculate a height value on each data object and return it from the itemHeight function. Do not measure DOM height inside renderItem - that causes layout thrashing and incorrect positions. Set estimatedItemHeight as a fallback.
Updated data does not appear on screen
If you mutate item data in place while using keyField, currently visible recycled rows are not refreshed automatically. Call refresh() after the change, or push the change through updateData() which always forces a fresh render.
Loading indicator stays on screen
The render is paused while the loading state is active. Always call updateData() before hideLoading() so the data is in place when rendering resumes. Calling hideLoading() first causes the empty state to flash before the data appears.
"Class constructor cannot be invoked without new" error
You called HCGVirtualScroll(...) without the new keyword. Always create the instance with new HCGVirtualScroll(...).
Duplicate key warning in the console
When keyField is set, every item must have a unique value for that field. A duplicate value triggers a one-time console warning and can cause DOM recycling to bind the wrong row. Make sure your key values are unique across the whole dataset.
Frequently Asked Questions (FAQ)
What is hcg-virtual-scroll?
hcg-virtual-scroll is a high-performance virtual scrolling library written in pure JavaScript. Instead of rendering every item in a list, it keeps only the visible rows in the DOM, allowing lists with 1,000,000 or more items to scroll smoothly without freezing the page.
Why should I use virtual scrolling?
Rendering thousands of DOM elements at once slows down the browser and uses large amounts of memory. Virtual scrolling renders only the items currently visible in the viewport, keeping the DOM small, memory low, and scrolling smooth even on low-end devices.
Does hcg-virtual-scroll require a framework?
No. It is written in vanilla JavaScript with zero dependencies. It works with plain div, ul, ol, and table containers and does not require React, Vue, or any build step.
Can I use hcg-virtual-scroll with React, Vue, or Svelte?
Yes. Because it manages its own DOM, you create the instance when the component mounts (useEffect in React, onMounted in Vue, onMount in Svelte), call updateData when the data changes, and call destroy() when the component unmounts. Row content is returned as an HTML string from renderItem rather than JSX or a template, and row clicks are handled with event delegation on the container.
My checkboxes lose their state after scrolling. How do I fix it?
Store the state as a property on each data object, for example item.checked, and read it back inside renderItem. The library reuses DOM nodes only while they stay in the buffer, so state must live on the data to survive items scrolling out of view.
Why is my list blank or items overlap?
The most common cause is an itemHeight value that does not match the actual rendered row height. Set itemHeight to the exact pixel height of your rows, and for variable heights pre-calculate a height value on each data object.