npm.io
1.0.5 • Published 17h ago

hcg-virtual-scroll

Licence
MIT
Version
1.0.5
Deps
0
Size
76 kB
Vulns
0
Weekly
408

hcg-virtual-scroll

High-performance virtual scrolling for vanilla JavaScript. Only the visible items are in the DOM at any time - no framework required, no dependencies.

  • Handles 1 000 000+ items without UI freeze
  • Fixed or dynamic item heights
  • Works with <div>, <ul>, <ol>, and <table>
  • DOM recycling, infinite scroll, chat/reverse mode
  • Empty state and loading state built in
  • ResizeObserver, adaptive overscan, ARIA support
  • Works with React, Vue, Svelte, or no framework at all

Live Demo · Documentation

Version: 1.0.5 · License: MIT · Author: HTML Code Generator


Demo

100,000 rows scrolling smoothly - only the visible rows are ever in the DOM.

hcg-virtual-scroll - smooth scrolling of 100,000 rows with no lag

The same scroll with a live counter - the rendered element count stays around 10 no matter how far you scroll.

hcg-virtual-scroll - DOM node count stays near 10 while scrolling 100,000 rows


Table of Contents

  1. Quick Start
  2. Working With Your Data
  3. Required HTML & CSS
  4. Options
  5. Callbacks
  6. Public Methods
  7. Usage - DIV List
  8. Usage - UL / OL List
  9. Usage - TABLE
  10. Dynamic Item Heights
  11. DOM Recycling with keyField
  12. Infinite Scroll
  13. Chat / Reverse Mode
  14. Empty State
  15. Loading State
  16. Live Config Hot-Swap
  17. Multiple Instances
  18. Using with React, Vue & Svelte
  19. Security Note

Quick Start

<!-- 1. Add the stylesheet -->
<link rel="stylesheet" href="hcg-virtual-scroll.css" />

<!-- 2. Create a container -->
<div id="myList" style="height: 500px;"></div>

<!-- 3. Load the library -->
<script src="hcg-virtual-scroll.js"></script>

<!-- 4. Initialise -->
<script>
  const data = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }));

  const vs = new HCGVirtualScroll(data, {
    container:  '#myList',
    itemHeight: 50,
    renderItem: (item, index) => `<div class="row">#${index} - ${item.name}</div>`,
  });
</script>

Important: Always use new HCGVirtualScroll(...). Calling without new throws a TypeError.


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: 'Alice',   email: 'alice@example.com',  role: 'Admin'  },
  { id: 2, name: 'Bob',     email: 'bob@example.com',    role: 'Member' },
  { id: 3, name: 'Carol',   email: 'carol@example.com',  role: 'Member' },
  // ... hundreds or thousands more
];

const vs = new HCGVirtualScroll(users, {
  container:  '#userList',
  itemHeight: 56,
  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,
  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,
  renderItem(item, index) {
    return `<div class="row">#${index} - $${item.toFixed(2)}</div>`;
  },
});

Load data from an API

Create an empty list, call showLoading() while fetching, then updateData() and hideLoading() when the response arrives:

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 existing data from your app

If you already have an array in your code, just pass it directly - no conversion needed.

// Data already in your app
const cart      = getCartItems();
const employees = store.getState().employees;
const results   = searchResults.filter(r => r.active);

const vs = new HCGVirtualScroll(results, {
  container:  '#resultList',
  itemHeight: 60,
  renderItem(item) {
    return `<div class="row">${item.title}</div>`;
  },
});

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


Real-world data shapes

The library works with whatever field names your API or database returns. You are not required to rename anything.

E-commerce products:

const products = [
  { product_id: 101, product_name: 'Laptop', price: 999.00, stock: 15, category: 'Electronics' },
  { product_id: 102, product_name: 'Mouse',  price: 29.99,  stock: 80, category: 'Accessories' },
];

const vs = new HCGVirtualScroll(products, {
  container:  '#productList',
  itemHeight: 64,
  renderItem(item) {
    return `<div class="row">
      <span>${item.product_name}</span>
      <span>$${item.price.toFixed(2)}</span>
      <span>${item.stock} in stock</span>
    </div>`;
  },
});

Blog posts:

const posts = [
  { slug: 'hello-world', title: 'Hello World', author: 'Alice', date: '2025-01-01', tags: ['intro'] },
  { slug: 'tips-js',     title: 'JS Tips',     author: 'Bob',   date: '2025-01-05', tags: ['js']    },
];

const vs = new HCGVirtualScroll(posts, {
  container:  '#postList',
  itemHeight: 72,
  renderItem(item) {
    return `<div class="row">
      <div class="post-title">${item.title}</div>
      <div class="post-meta">By ${item.author} on ${item.date}</div>
    </div>`;
  },
});

Chat messages:

const messages = [
  { msg_id: 1, sender: 'Alice', body: 'Hey!',        timestamp: '10:01 AM' },
  { msg_id: 2, sender: 'Bob',   body: 'Hi there!',   timestamp: '10:02 AM' },
  { msg_id: 3, sender: 'Alice', body: 'How are you?', timestamp: '10:03 AM' },
];

const vs = new HCGVirtualScroll(messages, {
  container:  '#chatList',
  itemHeight: 54,
  reverse:    true,
  renderItem(item) {
    const isMe = item.sender === 'Alice';
    return `<div class="chat-row ${isMe ? 'me' : 'other'}">
      <div class="bubble">${item.body}</div>
      <div class="time">${item.timestamp}</div>
    </div>`;
  },
});

Pre-calculate heights for dynamic rows

When item heights vary (expandable rows, multi-line text, cards of different sizes), calculate the height per item before passing the array in. This avoids DOM measurement during scrolling.

// Add a height property to each item based on its content
const posts = rawPosts.map(post => ({
  ...post,
  height: post.body.length > 100 ? 120 : 60,  // taller for long posts
}));

const vs = new HCGVirtualScroll(posts, {
  container:           '#postList',
  itemHeight:          item => item.height,    // read the pre-calculated value
  estimatedItemHeight: 80,                     // fallback if height is missing
  renderItem(item) {
    return `<div class="post-row">
      <strong>${item.title}</strong>
      <p>${item.body}</p>
    </div>`;
  },
});

Tips for large datasets
// 1. Filter and sort ONCE before passing in - not on every scroll
const prepared = rawData
  .filter(item => item.active)
  .sort((a, b) => a.name.localeCompare(b.name));

const vs = new HCGVirtualScroll(prepared, { ... });

// 2. When the filter changes, pass a new array - do not mutate
searchInput.addEventListener('input', e => {
  const q = e.target.value.toLowerCase();
  vs.updateData(rawData.filter(item => item.name.toLowerCase().includes(q)));
});

// 3. For huge lists (100k+ items), use append() for pagination
//    instead of passing all items at once
const vs = new HCGVirtualScroll(firstPage, {
  ...opts,
  onReachEnd() {
    fetchNextPage().then(page => vs.append(page));
  },
});

Required HTML & CSS

The library injects two internal elements inside your container:

container
  |- .hcg-vs-phantom   (invisible spacer - sets the scrollbar height)
  \- .hcg-vs-content   (visible items - moved with translateY)

Your container only needs:

#myList {
  height: 500px;       /* fixed height required */
  overflow-y: auto;    /* handled automatically by .hcg-vs-container class */
}

The library adds the hcg-vs-container class to your element automatically. You do not need to add it yourself.


Options

Required
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
Option Type Default Description
itemHeight number or Function 65 Fixed height in px, or (item, index) => number for dynamic heights. A fixed number must be greater than 0 or the constructor throws
estimatedItemHeight number same as itemHeight Fallback height used when the itemHeight function returns 0, a negative number, or a non-number
bufferSize number 3 Extra items rendered above and below the visible area
containerHeight number - Sets the container height in px - useful when height is not set in CSS. Accepts a number (500) or a numeric string ('500')
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

Callbacks

All callbacks are optional. Pass them in the options object.

onScroll(scrollTop, range)

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

onScroll(scrollTop, { start, end }) {
  console.log('Scrolled to', scrollTop, 'px');
  console.log('Visible range:', start, '-', 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 actually changes - not on every scroll pixel.

onVisibleRangeChange({ start, end }) {
  console.log('Range changed:', start, '-', end);
}

onRender(startIndex, endIndex)

Fires after every DOM update (after new items are written to the DOM).

onRender(start, end) {
  console.log(`DOM updated: items ${start} to ${end}`);
}

onReachEnd({ start, end, total }) / onLoadMore (alias)

Fires once when scrolling near the end of the list. Resets when the user scrolls back up. Use for infinite loading.

onReachEnd({ start, end, total }) {
  loadMoreItems().then(batch => vs.append(batch));
}

Control how early it fires with reachEndThreshold (default: 5 items from end). The threshold is measured against the actual viewport edge, not the buffered/overscanned range - so it fires when the real last visible item is within reachEndThreshold of the end, regardless of bufferSize.

The start and end values in the callback payload report the full rendered range (including buffer).


onReachStart({ start, end, total })

Fires once when scrolling near the top of the list. Use for loading history (e.g. chat). Like onReachEnd, the threshold is measured against the actual viewport edge, not the buffered range.

onReachStart({ start, end, total }) {
  loadHistory().then(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({ width, height }) {
  console.log('Container resized to', width, 'x', height);
}

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
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. Call after mutating item data in place (see DOM Recycling)
getVisibleRange() Returns { start, end } of the rendered range (includes the overscan buffer, not viewport-only)
destroy() Remove all event listeners and clear the generated DOM. Safe to call more than once

Usage - DIV List

The simplest and most common setup. Each item is a <div>.

HTML:

<div id="divList" style="height: 500px;"></div>

CSS:

.row-item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
  background: #fff;
  box-sizing: border-box;
}
.row-item:hover { background: #f5f5f5; }

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-item">
      <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 - UL List

Use <ul> or <ol> as the container. Each renderItem must return a <li>. The setup is the same for both - swap the tag and container id as needed.

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 to container. For <ul>, wrap it in a <div> and set overflow-y: auto on the list via CSS. For <ol>, you can pass the <ol> directly with overflow-y: auto inline as shown in the comment above.

CSS:

#ul-list {
  overflow-y: auto;
}
.list-item {
  display: flex;
  align-items: center;
  padding: 10px 16px;
  border-bottom: 1px solid #f0f0f0;
  background: #fff;
  box-sizing: border-box;
}

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="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 - TABLE

For tables, the scroll container is a <div> that the library mounts on - never the <table> or <tbody>. Place the header <table> above that scroll container, and render each row as its own small table that shares the same fixed column widths. This keeps valid markup and aligns every column with the header.

Why not mount on <tbody>? The library positions rows using an internal spacer element and a CSS transform, which require plain block elements. Those are not valid inside a <table> or <tbody> - the browser ejects them and the layout breaks. Rendering one small table per row avoids this and supports the full 1,000,000+ row range.

CSS - shared column widths, header background on the wrapper, and horizontal scroll on small screens:

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

HTML - header above the scroll container the library mounts on:

<link rel="stylesheet" href="hcg-virtual-scroll.css">

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

<script src="hcg-virtual-scroll.js"></script>

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 vs = new HCGVirtualScroll(rows, {
  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. Each rendered row is wrapped in .hcg-vs-item so the configured itemHeight is applied reliably.

Small screens: Wrap the header and scroll container in a shared overflow-x: auto parent with a min-width (for example 520px) so columns stay readable on narrow viewports. Put the header background on the header wrapper (not <thead>) and set padding-right to the scrollbar width (offsetWidth - clientWidth) so columns align without a white gap beside the scrollbar.


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.


DOM Recycling with keyField

When keyField is set, the library reuses existing DOM nodes instead of recreating them. This preserves input values, checkbox states, focus, and custom event listeners when scrolling.

const items = Array.from({ length: 1000 }, (_, i) => ({
  id:    i,      // <-- used as the unique key
  name:  `Person ${i}`,
  email: `person${i}@example.com`,
}));

const vs = new HCGVirtualScroll(items, {
  container:  '#recycleList',
  itemHeight: 60,
  keyField:   'id',    // enables DOM recycling

  renderItem(item) {
    return `<div class="row-item">
      <input type="checkbox" title="Select ${item.name}" />
      <span>${item.name}</span>
      <span style="color:#888">${item.email}</span>
    </div>`;
  },
});

// Scroll away and back - checked checkboxes stay checked

Rules for keyField:

  • Every item must have the field defined and non-null
  • Values must be unique across the dataset
  • If an item's key is missing or null, that item falls back to a fresh render
  • If duplicate keys are detected, the library logs a one-time console.warn - recycling may bind the wrong row to an index
Updating data with keyField - call refresh()

During scrolling, recycled rows keep their existing DOM nodes (this is what preserves focus and checkbox state). Because of this, if you mutate item data in place and the row is still on screen, the visible DOM is not updated automatically.

// mutate data in place
items[5].name = 'Updated Name';

// push the change to the currently visible recycled row
vs.refresh();

updateData(), updateConfig(), and refresh() all force a fresh render, so any data change pushed through them updates the DOM correctly. Only direct in-place mutation without one of these calls can leave a visible row stale.

Tip: Store per-row UI state (checked, expanded, selected) as a property on the data object - for example item.checked. Then renderItem reads it back on every fresh render, and the state survives both recycling and refresh(). See the DOM Recycling demo for this pattern.


Infinite Scroll

Use onReachEnd (or its alias onLoadMore) to load more data automatically.

let loading = false;

const vs = new HCGVirtualScroll(initialData, {
  container:         '#infiniteList',
  itemHeight:        56,
  reachEndThreshold: 10,   // fire when 10 items from the end

  renderItem: (item, i) => `<div class="row-item">#${i} ${item.name}</div>`,

  onReachEnd({ total }) {
    if (loading) return;
    loading = true;

    fetchNextPage(total).then(batch => {
      vs.append(batch);
      loading = false;
    });
  },
});

Key points:

  • onReachEnd fires once per approach to the end - it resets when the user scrolls back up past the threshold
  • Use the loading flag to prevent duplicate requests
  • append() is O(new items) - it does not rebuild the full position cache

Chat / Reverse Mode

Set reverse: true to anchor the list to the bottom. New messages appended while the user is at the bottom auto-scroll down. Scrolling to the top triggers onReachStart for loading history.

const vs = new HCGVirtualScroll(initialMessages, {
  container:           '#chatList',
  itemHeight:          msg => msg.height,
  estimatedItemHeight: 72,
  reverse:             true,   // anchor to bottom

  renderItem(msg) {
    const isMe = msg.author === 'me';
    return `<div style="display:flex;justify-content:${isMe ? 'flex-end' : 'flex-start'};padding:6px 12px;">
      <div style="
          max-width:70%;padding:8px 12px;border-radius:12px;
          background:${isMe ? '#2563eb' : '#fff'};
          color:${isMe ? '#fff' : '#222'};
          border:${isMe ? 'none' : '1px solid #e2e8f0'}">
        ${msg.text}
      </div>
    </div>`;
  },

  onReachStart() {
    loadHistory().then(older => vs.prepend(older));
  },
});

// Send a new message - auto-scrolls if user is at bottom
function sendMessage(text) {
  vs.append([{ id: Date.now(), author: 'me', text, height: 54 }]);
}

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.name}</div>`,
});

vs.showLoading();

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

Multiple Instances

Each instance is completely independent. Create as many as needed on the same page.

const vsUsers = new HCGVirtualScroll(usersData, {
  container:  '#userList',
  itemHeight: 56,
  renderItem: renderUser,
});

const vsProducts = new HCGVirtualScroll(productsData, {
  container:  '#productList',
  itemHeight: 80,
  renderItem: renderProduct,
});

const vsMessages = new HCGVirtualScroll(messagesData, {
  container:  '#messageList',
  itemHeight: msg => msg.height,
  reverse:    true,
  renderItem: renderMessage,
});

// Clean up when done
window.addEventListener('unload', () => {
  vsUsers.destroy();
  vsProducts.destroy();
  vsMessages.destroy();
});

Using with React, Vue & Svelte

hcg-virtual-scroll is framework-agnostic. It manages its own DOM inside the container, so in any framework you create the instance once when the element mounts, push new data when it changes, and call destroy() on unmount.

React

Try it live on StackBlitz

import { useRef, useEffect } from 'react';
import HCGVirtualScroll from 'hcg-virtual-scroll';
import 'hcg-virtual-scroll/hcg-virtual-scroll.css';

function VirtualList({ data }) {
  const containerRef = useRef(null);
  const vsRef = useRef(null);

  // create once on mount, destroy on unmount
  useEffect(() => {
    vsRef.current = new HCGVirtualScroll(data, {
      container:  containerRef.current,
      itemHeight: 50,
      keyField:   'id',
      renderItem: (item, i) => `<div class="row">#${i} - ${item.name}</div>`,
    });
    return () => vsRef.current.destroy();
  }, []);

  // push new data when the prop changes
  useEffect(() => {
    if (vsRef.current) vsRef.current.updateData(data);
  }, [data]);

  return <div ref={containerRef} style={{ height: 500 }} />;
}
Vue 3

Try it live on StackBlitz

<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: Array });
const el = ref(null);
let vs = null;

onMounted(() => {
  vs = new HCGVirtualScroll(props.data, {
    container:  el.value,
    itemHeight: 50,
    keyField:   'id',
    renderItem: (item, i) => `<div class="row">#${i} - ${item.name}</div>`,
  });
});

watch(() => props.data, (newData) => vs && vs.updateData(newData));
onBeforeUnmount(() => vs && vs.destroy());
</script>

<template>
  <div ref="el" style="height: 500px"></div>
</template>
Svelte

Try it live on StackBlitz

<script>
  import { onMount, onDestroy } from 'svelte';
  import HCGVirtualScroll from 'hcg-virtual-scroll';
  import 'hcg-virtual-scroll/hcg-virtual-scroll.css';

  export let data = [];
  let el, vs;

  onMount(() => {
    vs = new HCGVirtualScroll(data, {
      container:  el,
      itemHeight: 50,
      keyField:   'id',
      renderItem: (item, i) => `<div class="row">#${i} - ${item.name}</div>`,
    });
  });

  $: if (vs) vs.updateData(data);     // reactive - runs when data changes
  onDestroy(() => vs && vs.destroy());
</script>

<div bind:this={el} style="height: 500px"></div>
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 - let it manage the rows.
  • renderItem returns HTML strings or DOM elements, not JSX / templates. You cannot place React/Vue/Svelte components or their event bindings inside a row.
  • Handle row clicks with event delegation on the container, reading data-vs-key:
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 data updates.
  • Update data through updateData() in a watcher / effect keyed on your data - do not mutate the array in place.

Security Note

renderItem output is inserted via innerHTML. If your data contains user-generated or untrusted content, you must sanitise it before returning from renderItem.

// Unsafe - do NOT do this with untrusted data
renderItem: item => `<div>${item.userInput}</div>`

// Safe - escape HTML entities
function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

renderItem: item => `<div>${escapeHtml(item.userInput)}</div>`

Alternatively, return an HTMLElement directly from renderItem and set content via textContent:

renderItem(item) {
  const el = document.createElement('div');
  el.className = 'row-item';
  el.textContent = item.name;  // textContent is always safe
  return el;
}

Browser Support

Feature Browsers
Core virtual scrolling All modern browsers, IE 11+
class syntax Chrome 49+, Firefox 45+, Safari 9+, Edge 13+
ResizeObserver Chrome 64+, Firefox 69+, Safari 13.1+, Edge 79+

For older browser support, transpile with Babel and use a ResizeObserver polyfill.


File Structure

hcg-virtual-scroll/
  hcg-virtual-scroll.js    Core library (class-based, no dependencies)
  hcg-virtual-scroll.css   Required styles
  index.html               Live demos (9 examples)
  README.md                This file

Built by HTML Code Generator - Live Demo · Documentation

Keywords