Virtual Scroll Implementation Guide

Complete code examples showing how virtual scrolling works

What is Virtual Scrolling?

Virtual scrolling renders only visible items in the viewport, keeping DOM size constant regardless of total dataset size. This allows smooth scrolling through millions of records.

Key Concepts:

  • Fixed item height - All items must have consistent, known height
  • Viewport calculation - Calculate which items are visible based on scroll position
  • Buffer zone - Render extra items above/below viewport for smooth scrolling
  • Absolute positioning - Position items at calculated offsets
  • DOM recycling - Remove items outside viewport, add items entering viewport

Example 1: Timeline Virtual Scroll

Custom implementation preserving CSS framework classes

HTML Structure

<div id="timeline-container" style="height: 400px; overflow-y: auto;">
    <ul class="pa-timeline pa-timeline--feed" id="timeline-list"></ul>
</div>

JavaScript Implementation

timeline-virtual-scroll.js
// Configuration
const container = document.getElementById('timeline-container');
const list = document.getElementById('timeline-list');

let scrollTop = 0;
const itemHeight = 50;        // Each timeline item is 50px tall
const totalItems = 5000;      // Total dataset size
const bufferSize = 10;        // Extra items to render
const viewportHeight = 400;   // Container height

function renderTimelineItems() {
    // Calculate visible range
    const startIndex = Math.floor(scrollTop / itemHeight);
    const endIndex = Math.ceil((scrollTop + viewportHeight) / itemHeight);

    // Add buffer zone
    const visibleStart = Math.max(0, startIndex - bufferSize);
    const visibleEnd = Math.min(totalItems, endIndex + bufferSize);

    // Set list height to maintain scroll
    list.style.height = `${totalItems * itemHeight}px`;
    list.style.position = 'relative';

    // Clear and render visible items
    list.innerHTML = '';

    for (let i = visibleStart; i < visibleEnd; i++) {
        const li = document.createElement('li');
        li.className = 'pa-timeline__item';  // Framework CSS class

        // Absolute positioning at calculated offset
        li.style.position = 'absolute';
        li.style.top = `${i * itemHeight}px`;
        li.style.left = '0';
        li.style.right = '0';

        // Render item content
        const time = `${String(9 + (i % 10)).padStart(2, '0')}:${String((i * 5) % 60).padStart(2, '0')}`;
        li.innerHTML = `
            <div class="pa-timeline__time">${time}</div>
            <div class="pa-timeline__content">
                <div class="pa-timeline__avatar">
                    <img src="avatar.jpg" alt="User">
                </div>
                <span>Action #${i}</span>
            </div>
        `;

        list.appendChild(li);
    }
}

// Listen to scroll events
container.addEventListener('scroll', () => {
    scrollTop = container.scrollTop;
    renderTimelineItems();
});

// Initial render
renderTimelineItems();

How It Works

  1. List height: Set to totalItems ร— itemHeight (5000 ร— 50px = 250,000px) to maintain scrollbar size
  2. Calculate range: Divide scroll position by item height to get visible indices (e.g., scrollTop 500px รท 50px = item 10)
  3. Add buffer: Render 10 extra items above/below for smooth scrolling (prevents white space during fast scroll)
  4. Position items: Use absolute positioning with calculated top offset (item 100 = 5000px from top)
  5. Clear DOM: Remove all items and re-render only ~20-30 visible ones on each scroll event
Result: Scrolling through 5000 items only renders ~20 at a time. DOM stays constant, performance stays smooth.

Example 2: Table Virtual Scroll

Using spacer rows to preserve table structure

HTML Structure

<div id="table-container" style="height: 400px; overflow-y: auto;">
    <table class="pa-table pa-table--striped">
        <thead style="position: sticky; top: 0; background: white; z-index: 10;">
            <tr>
                <th>#</th>
                <th>Name</th>
                <th>Email</th>
                <th>Status</th>
            </tr>
        </thead>
        <tbody id="table-body"></tbody>
    </table>
</div>

JavaScript Implementation

table-virtual-scroll.js
// Configuration
const container = document.getElementById('table-container');
const tbody = document.getElementById('table-body');

let scrollTop = 0;
const itemHeight = 38;        // Table row height
const totalItems = 10000;     // Total rows
const bufferSize = 10;
const viewportHeight = 400;

function renderTableRows() {
    // Calculate visible range
    const startIndex = Math.floor(scrollTop / itemHeight);
    const endIndex = Math.ceil((scrollTop + viewportHeight) / itemHeight);
    const visibleStart = Math.max(0, startIndex - bufferSize);
    const visibleEnd = Math.min(totalItems, endIndex + bufferSize);

    // Clear tbody
    tbody.innerHTML = '';

    // Top spacer row
    if (visibleStart > 0) {
        const topSpacer = document.createElement('tr');
        topSpacer.style.height = `${visibleStart * itemHeight}px`;
        topSpacer.innerHTML = '<td colspan="4"></td>';
        tbody.appendChild(topSpacer);
    }

    // Visible rows
    for (let i = visibleStart; i < visibleEnd; i++) {
        const tr = document.createElement('tr');
        tr.innerHTML = `
            <td>${i + 1}</td>
            <td>John Doe</td>
            <td>john.doe@example.com</td>
            <td><span class="pa-badge pa-badge--success">Active</span></td>
        `;
        tbody.appendChild(tr);
    }

    // Bottom spacer row
    if (visibleEnd < totalItems) {
        const bottomSpacer = document.createElement('tr');
        bottomSpacer.style.height = `${(totalItems - visibleEnd) * itemHeight}px`;
        bottomSpacer.innerHTML = '<td colspan="4"></td>';
        tbody.appendChild(bottomSpacer);
    }
}

// Listen to scroll events
container.addEventListener('scroll', () => {
    scrollTop = container.scrollTop;
    renderTableRows();
});

// Initial render
renderTableRows();

Table-Specific Approach

Tables require a different approach because we can't use absolute positioning without breaking table layout:

  1. Spacer rows: Empty <tr> elements with calculated height replace absolute positioning
  2. Top spacer: Pushes visible rows down by visibleStart ร— itemHeight (e.g., item 100 = 3800px spacer)
  3. Bottom spacer: Maintains scrollbar size for remaining rows below visible range
  4. Sticky header: position: sticky; top: 0 keeps column headers visible during scroll
  5. Colspan: Spacer uses colspan="4" to span all columns
Visual representation:
[Top Spacer: 3800px] โ†’ [Visible Rows: ~20 rows] โ†’ [Bottom Spacer: remaining height]

Performance Tips & Optimizations

โœ“ Do This

  • Use fixed heights - Essential for accurate offset calculations
  • Keep rendering simple - Complex DOM structures slow down rendering
  • Add buffer zone - Prevents white space during fast scrolling
  • Cache data - Pre-compute or cache item content when possible
  • Use CSS transforms - transform: translateY() can be faster than top

โœ— Avoid This

  • Variable heights - Breaks offset calculations completely
  • Heavy rendering - Complex nested elements in each item
  • Synchronous operations - Don't fetch data synchronously during scroll
  • Layout thrashing - Don't read and write to DOM in same cycle
  • Too small buffer - User will see white space during scroll

Advanced: Debouncing (Optional)

For very fast scrolling, you can debounce the render function:

let renderTimeout;
container.addEventListener('scroll', () => {
    scrollTop = container.scrollTop;

    // Clear previous timeout
    clearTimeout(renderTimeout);

    // Debounce render by 16ms (~60fps)
    renderTimeout = setTimeout(() => {
        renderTableRows();
    }, 16);
});

When to Use Virtual Scroll vs Infinite Scroll

Virtual Scroll (Constant DOM)

Best for:

  • Tables with thousands+ rows
  • Log viewers (uniform entries)
  • File/folder lists
  • Data grids
  • Search results with uniform layout

Characteristics:

  • โœ“ Handles millions of items
  • โœ“ Constant DOM size (~20 items)
  • โœ— Requires fixed item heights
  • โœ— More complex to implement

Infinite Scroll (Growing DOM)

Best for:

  • Social media feeds
  • News/article lists
  • Product catalogs
  • Image galleries
  • Content with variable heights

Characteristics:

  • โœ“ Works with variable heights
  • โœ“ Simpler to implement
  • โœ— DOM grows with each load
  • โœ— Limited to ~1000-2000 items
โšก Performance Benchmark:
Virtual Scroll: 10,000 rows = ~20 DOM elements
Infinite Scroll: 10,000 rows = 10,000 DOM elements (browser will struggle)
Type to search or use /p for products, /o for orders, /u for users, /i for invoices

Settings

๐Ÿ‘ค

John Doe

Administrator