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

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

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

Code links:

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.

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

stats bar:
Scroll: 0px
Visible: 019
DOM nodes: 20

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.

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.


NPM Usage

Install the package with npm or yarn.

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

Quick Start

Create a container element with a fixed height, then initialize the library with your data array and a renderItem function.

HTML
<div id="myList" class="hcg-vs-parent" style="height: 400px;"></div>

Initialise:

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:  '#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.

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

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:  '#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:

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

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

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

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

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="myList" 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:  '#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.

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

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:  '#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 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
<!-- 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:

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

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');
}

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.

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.

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.js12.7 KB3.4 KB
hcg-virtual-scroll.min.css0.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([]).

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

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

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

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

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

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);
}

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: '#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.

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.

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.