JavaScript Virtual Scrolling Library for Large Lists and Infinite Scroll


By HTML Code Generator

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.

What Is hcg-virtual-scroll?

hcg-virtual-scroll is a lightweight vanilla JavaScript library for virtual scrolling - rendering only the rows currently visible in a scroll container plus a small buffer. Pass an array of data and a renderItem function; the library handles scroll position math, phantom height, DOM recycling, and callbacks - without React, Vue, or any other dependency.

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.

hcg-virtual-scroll diagram: a list with many rows on screen produces only about eight row elements in the DOM, nested inside hcg-vs-container, hcg-vs-phantom, and hcg-vs-content

Why Use hcg-virtual-scroll

Rendering thousands of DOM nodes makes pages slow. Virtual scrolling solves this by keeping the DOM small while the scroll bar still reflects the full dataset size.

  • Zero dependencies - one JS file and one CSS file; CommonJS and browser global, no build step required.
  • Massive lists - 1,000,000+ items without UI freeze; only visible rows stay in the DOM.
  • Fixed and dynamic heights - uniform rows or per-item height functions.
  • DOM recycling - reuse nodes via keyField to preserve checkbox and input state.
  • Infinite scroll - onReachEnd + append() for paginated loading.
  • Chat / reverse mode - anchor to the bottom with reverse: true and prepend().
  • Tables - render each row as a mini-table with a sticky header above the scroll container.
  • Live updates - updateData(), updateConfig(), and refresh() without re-creating the instance.
  • Adaptive overscan - buffer grows automatically during fast scrolling.
  • Resize-aware - re-renders when the container resizes (ResizeObserver).
  • Accessible - role="list" / role="listitem" and optional ariaLabel.

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. see the See the full interactive demo page.

10 000 rows - itemHeight: 50 - bufferSize: 3 - adaptiveOverscan: true

stats bar:
Scroll: 0px
Visible: 0 โ€“ 19
DOM nodes: 20

Comparison with Alternatives

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.

Feature hcg-virtual-scroll Render all rows React-only libraries
DependenciesNoneNoneReact required
DOM nodes at scroll~20-40 (visible + buffer)All rows~20-40
100,000+ rowsSmoothBrowser hangsSmooth (with React)
Vanilla JS / script tagYesYesNo
Dynamic row heightsYes (pre-calculated)YesYes
DOM node recyclingYes (keyField)n/aVaries
Infinite scrollYes (append / onReachEnd)ManualVaries
Reverse / chat modeYesManualVaries
Runtime config changesYes (updateConfig)ManualVaries

Installation

Direct Script Include

Download the files and reference them in your HTML. This is the simplest way to get started - no build tools needed.

HTML
<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.


Or install it from npm:

Install the package with npm or yarn.

Command Line
npm install hcg-virtual-scroll

Then import it into your project. The library supports CommonJS and ES module style imports.

JavaScript
// 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.

HTML
<!-- 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.

HTML
<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>

Basic usage

Create a scroll container, pass your data array, and provide a renderItem function that returns HTML or a DOM element for each row.

Usage
HTML
<div id="list" style="height:400px;overflow-y:auto;"></div>
CSS
.row {
	display: flex;
	align-items: center;
	padding: 0 16px;
	border-bottom: 1px solid #eee;
	background: #fff;
	box-sizing: border-box;
	gap: 10px;
}

.row:hover {
	background: #f5f5f5;
}
JavaScript
// 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:  '#list',
  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.

JavaScript
renderItem: function(item, index) {
  return `<div class="row"> ${index} - ${item.name} - ${item.email} </div>`;
}

Features

hcg-virtual-scroll Features:

  • Zero dependencies - plain JavaScript, works with a script tag or bundler.
  • Fixed and dynamic heights - number or per-item function with estimated fallback.
  • Phantom scroll area - full scroll range without rendering every row.
  • Height compression - handles extremely tall virtual lists via maxHeight.
  • Adaptive overscan - buffer expands during fast scrolling.
  • DOM recycling - keyField reuses nodes to preserve state.
  • Infinite scroll - onReachEnd, append(), duplicate-request guard.
  • Reverse mode - bottom-anchored lists for chat and activity feeds.
  • History loading - onReachStart + prepend() for older items.
  • Live data updates - updateData(), refresh(), clear().
  • Runtime reconfiguration - updateConfig() without re-creating the instance.
  • Empty and loading states - built-in placeholders via emptyText / loadingText.
  • Scroll API - scrollTo(), scrollToTop(), scrollToBottom().
  • ResizeObserver - re-renders when the container resizes.
  • Accessible markup - list roles and optional ariaLabel.

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.

CallbackFired whenArguments
onScrollOn every scroll event (via rAF).scrollTop, { start, end }
onVisibleRangeChangeWhen the rendered index range changes.{ start, end, rawStart, rawEnd }
onRenderAfter each render pass completes.{ start, end }
onReachEndUser scrolls within reachEndThreshold items of the end. Resets when scrolling back up.{ start, end, total }
onReachStartUser scrolls to the top (history loading in reverse mode).{ start, end, total }
onResizeContainer size changes (ResizeObserver).{ width, height }
onScroll(scrollTop, range)

Fires on every scroll event, throttled to one call per animation frame.

JavaScript
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.

JavaScript
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.

JavaScript
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
JavaScript
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.

JavaScript
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.

JavaScript
onResize: function(size) {
  console.log('Container is now ' + size.width + ' x ' + size.height + ' px');
}

Instance methods

Methods available on the instance returned by new HCGVirtualScroll(data, options).

Data updates
MethodDescription
updateData(newData, keepScrollPosition?)Replace the dataset. Pass true to preserve scroll position.
updateItems(newData)Alias for updateData(newData, false).
append(newItems)Add items to the end. Preserves scroll position; auto-scrolls in reverse mode when at bottom.
prepend(newItems)Add items to the start. Adjusts scroll position so the view does not jump.
clear()Empty the list and reset scroll state.
Scroll control
MethodDescription
scrollTo(index, smooth?)Scroll so the row at index is at the top.
scrollToTop(smooth?)Scroll to the first row.
scrollToBottom(smooth?)Scroll to the last row.
Configuration and render
MethodDescription
updateConfig(options)Change any option at runtime and re-render.
refresh()Force a full re-render of visible rows.
Read state
MethodDescription
getVisibleRange()Returns { start, end } of currently rendered indices.
getScrollPosition()Returns current scrollTop in pixels.
getData()Returns the current data array.
isLoading()Returns whether the instance is in a loading state.
Lifecycle
MethodDescription
destroy()Remove listeners, disconnect ResizeObserver, and clear the container.

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):

JavaScript
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:  '#list-object',
  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:
JavaScript
const countries = ['Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola'];

const vs = new HCGVirtualScroll(countries, {
  container:  '#country-list',
  itemHeight: 44,
  emptyText: 'No results found',
  renderItem(item, index) {
    return `<div class="row">${index + 1}. ${item}</div>`;
  },
});
Filtered Array

Update the list when your data changes:

JavaScript
// 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.

HTML
<div id="div-list" style="height: 500px;"></div>
JavaScript
const data = Array.from({ length: 50000 }, (_, i) => ({
  id:   i,
  name: `User ${i}`,
  role: i % 3 === 0 ? 'Admin' : 'Member',
}));

const vs = new HCGVirtualScroll(data, {
  container:  '#div-list',
  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.

HTML
<div style="height: 500px; overflow: hidden;">
  <ul id="ul-list" style="height: 100%; list-style: none; padding: 0; margin: 0;"></ul>
</div>

<!-- <ol id="ol-list" 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.

JavaScript
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:  '#ul-list',
  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>`;
  },
});

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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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, smoothsmooth, ~60 FPS~14
10,000~10,000 nodes, sluggishsmooth, ~60 FPS~14
100,000freezes or may crash the tabsmooth, ~60 FPS~14
1,000,000tab crashessmooth, ~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.

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([]).

Empty Message Text
JavaScript
const vs = new HCGVirtualScroll([], {
  container:  '#empty-state',
  itemHeight: 56,
  emptyText:  'No results found',
  renderItem: item => `<div class="row">${item.name}</div>`,
});

The default empty text is wrapped in .hcg-vs-empty for styling:

CSS
.hcg-vs-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: #999;
  font-size: 0.95rem;
}

Empty Message HTML

Use emptyHTML for a fully custom layout:

JavaScript
const vs = new HCGVirtualScroll([], {
  container:  '#empty-state-html',
  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>`,
});

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.

JavaScript
const vs = new HCGVirtualScroll([], {
  container:   '#fetch-data',
  itemHeight:  56,
  loadingText: 'Fetching data...',
  emptyText:   'No results found',
  renderItem:  item => `<div class="row">${item.title}</div>`,
});

vs.showLoading();

fetch('https://jsonplaceholder.typicode.com/posts')
  .then(res => res.json())
  .then(data => {
    vs.updateData(data);
    vs.hideLoading();
  });

Use loadingHTML for a custom spinner:

JavaScript
const vs = new HCGVirtualScroll([], {
  container:   '#fetch-data',
  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>`,
});
vs.showLoading();

The default loading text is wrapped in .hcg-vs-loading for styling:

CSS
.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():

JavaScript
if (!vs.isLoading()) {
  vs.append(newItems);
}

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:

JavaScript
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:

JavaScript
vs.hideLoading();   // renders with no data - empty state appears
vs.updateData(data); // renders again with real data

Virtual Scrolling Tables

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 through table-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.

HTML
<div class="vs-table-wrap">
  <div class="vs-table-inner">
    <div id="tableHeader">
      <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="tableScroll" style="height:400px;overflow-y:auto;"></div>
  </div>
</div>
CSS
.vs-table-wrap {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

.vs-table-inner {
  min-width: 520px;
}

#tableHeader {
  width: 100%;
  box-sizing: border-box;
  background: #f9fafb;
  border-bottom: 2px solid #e5e7eb;
}

#tableHeader table,
#tableScroll .hcg-vs-item > table {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  box-sizing: border-box;
}

#tableHeader th,
#tableScroll .hcg-vs-item td {
  padding: 10px 14px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#tableHeader th.col-id,
#tableScroll .hcg-vs-item td.col-id { width: 80px; color: #999; }

#tableHeader th.col-status,
#tableScroll .hcg-vs-item td.col-status { width: 110px; font-weight: 600; }

@media (max-width: 600px) {
  .vs-table-inner { min-width: 480px; }
}
JavaScript
function syncHeader() {
  const list = document.getElementById('tableScroll');
  const header = document.getElementById('tableHeader');
  if (!list || !header) return;
  header.style.paddingRight = (list.offsetWidth - list.clientWidth) + 'px';
}

const STATUSES = ['Active', 'Inactive', 'Pending'];
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,
  keyField:   'id',

  renderItem(item) {
    const color = item.status === 'Active' ? '#16a34a' : '#dc2626';
    return `<table><tr>
      <td class="col-id">${item.id}</td>
      <td>${item.name}</td>
      <td>${item.email}</td>
      <td class="col-status" style="color:${color}">${item.status}</td>
    </tr></table>`;
  },

  onResize() { syncHeader(); },
});

syncHeader();
window.addEventListener('resize', syncHeader);

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.

Live Config Hot-Swap

Change any option at runtime without destroying and recreating the instance.

JavaScript
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.

JavaScript
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: '#dynamic-height',
  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.

JavaScript React
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:

JavaScript React
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>`}
    />
  );
}
Edit React example on StackBlitz
Vue 3

Create the instance in onMounted using a template ref, watch the data prop to call updateData(), and destroy it in onBeforeUnmount.

Vue 3 - VirtualList.vue
<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:

Vue HTML
<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>
Edit Vue example on StackBlitz
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.

HTML - VirtualList.svelte
<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:

HTML - VirtualList.svelte
<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} />
Edit Svelte example on StackBlitz
Required CSS for the rows

renderItem returns an HTML string, so style its class yourself. The row heightshould match itemHeight.

CSS
.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.

JavaScript
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.

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.