JavaScript Virtual Scrolling - Live Demos

Interactive examples of hcg-virtual-scroll, a high-performance vanilla JavaScript virtual scrolling library that renders 1,000,000+ list items smoothly with no framework and no dependencies. Scroll any demo below and only the visible rows stay in the DOM - open your browser DevTools to watch the element count stay small while you scroll.

Demo 1: Fixed Height List - 10,000 Rows

The most common virtual scrolling setup. Every row shares the same height, so the library calculates positions instantly. This example holds 10,000 rows but keeps only the visible ones in the DOM, so scrolling stays smooth no matter how large the list grows.

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

Scroll: 0px Visible: - - - DOM nodes: -
View Code
const vs = new HCGVirtualScroll(data, {
  container:        '#list1',
  itemHeight:       50,
  bufferSize:       3,
  adaptiveOverscan: true,

  renderItem(item, i) {
    return `<div class="row">
      <span>#${i}</span>
      <span>${item.name}</span>
      <span class="badge">${item.badge}</span>
    </div>`;
  },

  onScroll(scrollTop, { start, end }) {
    console.log('scroll:', scrollTop, 'visible:', start, '-', end);
  },
});

Demo 2: Dynamic Row Heights

When rows vary in height - cards, multi-line text, or expandable items - pass a function to itemHeight. Each item's height is pre-calculated, so the library positions variable-height rows correctly without measuring the DOM during scroll.

5 000 rows · itemHeight: function · heights 44 - 96 px

Scroll: 0px Visible: - - - DOM nodes: -
View Code
// each item has a pre-calculated height property
const data = items.map(item => ({
  ...item,
  height: item.body.length > 80 ? 96 : 44,
}));

const vs = new HCGVirtualScroll(data, {
  container:           '#list2',
  itemHeight:          item => item.height,  // function per item
  estimatedItemHeight: 60,                   // fallback
  bufferSize:          4,

  renderItem(item) {
    return `<div style="height:${item.height}px">
      <strong>${item.title}</strong>
      <p>${item.body}</p>
    </div>`;
  },
});

Demo 3: DOM Recycling with keyField keyField

Set keyField to a unique property and the library reuses existing DOM nodes instead of recreating them on every scroll. This preserves checkbox state, focus, and input values. Tick a few boxes, scroll away, then scroll back - the state is kept.

1 000 cards · keyField: "id" · check items then scroll away and back - checkboxes are preserved

Scroll: 0px Visible: - - - DOM nodes: - Checked: 0
View Code
const data = makeItems(1000);  // each item: { id, name, ... }

const vs = new HCGVirtualScroll(data, {
  container:  '#list3',
  itemHeight: 72,
  keyField:   'id',   // enables DOM recycling - reuses existing elements

  renderItem(item) {
    // item.checked stored on data object - survives scrolling out of buffer
    return `<div class="row">
      <input type="checkbox" ${item.checked ? 'checked' : ''} class="item-cb" />
      <span>${item.name}</span>
    </div>`;
  },
});

// event delegation - update item.checked directly on the data object by index
document.getElementById('list3').addEventListener('change', function (e) {
  if (!e.target.classList.contains('item-cb')) return;
  const row = e.target.closest('[data-vs-key]');
  if (!row) return;
  const id = parseInt(row.dataset.vsKey, 10);
  data[id].checked = e.target.checked;  // write state back to data
});

// uncheck all - reset data then refresh DOM
function uncheckAll() {
  data.forEach(item => item.checked = false);
  vs.refresh();
}

Demo 4: Change Options at Runtime with updateConfig()

Update any option - row height, render function, or buffer size - without destroying and recreating the instance. The list re-renders instantly with the new settings, making it easy to switch layouts or view modes on the fly.

2 000 rows · change itemHeight / renderItem without re-creating the instance

Scroll: 0px Visible: - - - DOM nodes: -
View Code
const vs = new HCGVirtualScroll(data, {
  container:  '#list4',
  itemHeight: 60,
  renderItem: renderDefault,
});

// change item height at runtime - no re-create needed
vs.updateConfig({ itemHeight: 36 });
vs.updateConfig({ itemHeight: 90 });

// swap the render function completely
vs.updateConfig({ renderItem: renderCompact });

// change multiple options at once
vs.updateConfig({ bufferSize: 8, adaptiveOverscan: false });

Demo 5: Infinite Scroll - Load More on Demand onReachEnd · append()

Use the onReachEnd callback to fetch and append more items as the user nears the bottom - the standard pattern for feeds, search results, and product listings. This demo simulates a 600 ms network request and loads 30 rows per batch.

Starts with 50 items · loads 30 more when near the end · simulated 600 ms network delay

Scroll: 0px Visible: - - - Total items: 50 Batches: 0
View Code
let loading = false;

const vs = new HCGVirtualScroll(initialData, {
  container:         '#list5',
  itemHeight:        56,
  reachEndThreshold: 8,   // fire when 8 items from the end
  renderItem: (item, i) => `<div class="row">#${i} ${item.name}</div>`,

  onReachEnd({ total }) {
    if (loading) return;  // prevent duplicate requests
    loading = true;

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

Demo 7: onReachEnd Trigger Inspector onReachEnd

See exactly when the onReachEnd callback fires. Adjust the threshold and watch the event log record each trigger as you scroll toward the end - useful for tuning when infinite-scroll loading should begin.

100 items · scroll near the bottom to fire the trigger · watch the event log

Scroll: 0px Visible: - - - Total: 100 Threshold: 5 items Triggers: 0
Scroll near the bottom
to fire onReachEnd
View Code
const vs = new HCGVirtualScroll(data, {
  container:         '#list7',
  itemHeight:        50,
  reachEndThreshold: 5,   // fires when last 5 items become visible
  renderItem: (item, i) => `<div class="row">${i} - ${item.name}</div>`,

  onReachEnd({ start, end, total }) {
    // fires once per approach - resets when user scrolls back up
    console.log('Reached end', { start, end, total });
  },
});

// change threshold at runtime
vs.updateConfig({ reachEndThreshold: 15 });

Demo 6: Chat / Reverse Mode reverse · prepend() · onReachStart

Set reverse: true to anchor the list to the bottom, just like a chat window or activity feed. New messages appear at the bottom and the view stays pinned, while scrolling to the top loads older history with prepend().

reverse: true · anchored to bottom · scroll to top to load older messages

Messages: 0 Visible: - - -
View Code
const vs = new HCGVirtualScroll(messages, {
  container:           '#list6',
  itemHeight:          msg => msg.height,
  estimatedItemHeight: 74,
  reverse:             true,   // anchor to bottom

  renderItem(msg) {
    const isMe = msg.author === 'me';
    return `<div class="chat-row ${isMe ? 'me' : 'bot'}">
      <div class="bubble">${msg.text}</div>
    </div>`;
  },

  onReachStart() {
    // user scrolled to top - load older history
    loadHistory().then(older => vs.prepend(older));
  },
});

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

Demo 8: Virtual Scrolling Table - 100,000 Rows TABLE container

Render a large data table with a sticky header and aligned columns. Each row is a small table that shares the header's fixed column widths, so the layout stays consistent while only the visible rows are rendered - letting a 100,000-row table scroll smoothly.

100 000 rows · sticky header · column widths kept in sync with table-layout: fixed

Scroll: 0px Visible: - - - DOM nodes: - Total rows: 100000
ID Name Email Status
View Code
<!-- header table sits ABOVE the scroll container -->
<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="list8" style="height:400px;overflow-y:auto;"></div>

const vs = new HCGVirtualScroll(rows, {
  container:  '#list8',
  itemHeight: 44,

  // each row is a mini-table using the SAME fixed column widths as the header
  renderItem(item) {
    return `<table style="width:100%;table-layout:fixed;"><tbody><tr>
      <td style="width:80px">${item.id}</td>
      <td>${item.name}</td>
      <td>${item.email}</td>
      <td style="width:110px">${item.status}</td>
    </tr></tbody></table>`;
  },
});