hcg-virtual-scroll - Live Demo

Interactive demos of hcg-virtual-scroll, a high-performance zero-dependency JavaScript virtual scrolling library. Scroll 1,000,000+ rows smoothly with only the visible rows in the DOM - try fixed and dynamic heights, DOM recycling, infinite scroll, chat mode, tables, and live search. Open DevTools and watch the DOM node count stay small while you scroll. Full documentation

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. Only visible rows stay in the DOM.

Try it

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

Scroll: 0px Visible: - - - DOM nodes: -
View usage 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);
  },
});

2. Dynamic row heights

When rows vary in height, pass a function to itemHeight. Each item's height is pre-calculated so variable-height rows position correctly without measuring the DOM during scroll.

Try it

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

Scroll: 0px Visible: - - - DOM nodes: -
View usage code
const data = items.map(item => ({
  ...item,
  height: item.body.length > 80 ? 96 : 44,
}));

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

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

3. DOM recycling with keyField keyField

Set keyField to a unique property and the library reuses existing DOM nodes instead of recreating them. Checkbox state survives scrolling away and back.

Try it

1 000 cards · keyField: "id" · tick boxes, scroll away, scroll back - state is kept

Scroll: 0px Visible: - - - DOM nodes: - Checked: 0
View usage code
const vs = new HCGVirtualScroll(data, {
  container:  '#list3',
  itemHeight: 72,
  keyField:   'id',

  renderItem(item) {
    return `<div class="row">
      <input type="checkbox" ${item.checked ? 'checked' : ''} class="item-cb" />
      <span>${item.name}</span>
    </div>`;
  },
});

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

4. Change options at runtime with updateConfig()

Update row height, render function, or buffer size without destroying the instance. The list re-renders instantly with the new settings.

Try it

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

Scroll: 0px Visible: - - - DOM nodes: -
View usage code
vs.updateConfig({ itemHeight: 36 });
vs.updateConfig({ renderItem: renderCompact });
vs.updateConfig({ bufferSize: 8, adaptiveOverscan: false });

5. Infinite scroll - load more on demand onReachEnd · append()

Use onReachEnd to fetch and append more items as the user nears the bottom. This demo simulates a 600 ms network request and loads 30 rows per batch.

Try it

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

Scroll: 0px Visible: - - - Total items: 50 Batches: 0
View usage code
const vs = new HCGVirtualScroll(initialData, {
  container:         '#list5',
  itemHeight:        56,
  reachEndThreshold: 8,
  renderItem: (item, i) => `<div class="row">#${i} ${item.name}</div>`,

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

6. Chat / reverse mode reverse · prepend() · onReachStart

Set reverse: true to anchor the list to the bottom like a chat window. New messages appear at the bottom; scrolling to the top loads older history with prepend().

Try it

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

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

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

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

vs.append([{ id: Date.now(), author: 'me', text: 'Hello!', height: 54 }]);

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.

Try it

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 usage code
const vs = new HCGVirtualScroll(data, {
  container:         '#list7',
  itemHeight:        50,
  reachEndThreshold: 5,

  onReachEnd({ start, end, total }) {
    console.log('Reached end', { start, end, total });
  },
});

vs.updateConfig({ reachEndThreshold: 15 });

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 mini-table sharing the header's fixed column widths so only visible rows are rendered.

Try it

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 usage code
const vs = new HCGVirtualScroll(rows, {
  container:  '#list8',
  itemHeight: 44,

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