Virtual List

A hundred thousand rows, scrolled like thirty - only what is on screen is rendered, with measured heights, keyboard navigation and infinite loading

Overview

E.virtualList() renders a long list inside a scrolling container, but only the rows in view (plus a few either side). Scrolling re-uses the rows it has, places them with a transform and leaves everything else out of the page, so a list of 100,000 records costs about as much as a list of thirty.

The container is the scroller. Give it a height in CSS - a fixed height, a max-height or a flex child with min-height: 0 - and the list fills it.

Key Features

  • Fixed or measured rows - itemHeight: 40, or 'auto' with a cached height per row
  • Stable scrolling - rows above the view that change height do not move what you are reading
  • Infinite loading - loadMore() near the end, with loading, end and retry rows
  • Keyed rows - keyField re-uses rows across updates; prepend() keeps your place
  • Keyboard - arrows, Page Up/Down, Home/End and Enter; the focused row is never lost
  • Safe - strings from render() are sanitised; return an Element for your own nodes

Basic Usage

const list = E.virtualList('#people', {
    items: people,                       // any array
    itemHeight: 40,
    keyField: 'id',
    render: (p) => `<strong>${_.escape(p.name)}</strong> ${_.escape(p.email)}`,
    onItemClick: (p) => openProfile(p.id)
});
list.scrollToIndex(500, {align: 'center'});

100,000 fixed-height rows

100,000 fixed-height rows
Every row is the same height, so the list never measures anything: the row under any scroll position is a division away. Drag the scrollbar from top to bottom, or click a row and use the keyboard. Rows in the page right now: 0.
Showing -
Every row is the same height, so the list never measures anything: the row under any scroll
position is a division away. Drag the scrollbar from top to bottom, or click a row and use the
keyboard. Rows in the page right now: <strong id="demo-big-dom" class="sx-vl-output">0</strong>.
<div class="sx-vl-bar mb-3">
    <label for="demo-big-index" class="form-label m-0">Go to row</label>
    <input id="demo-big-index" class="form-input" type="number" min="0" max="99999" value="50000">
    <button id="demo-big-go" class="btn btn-sm btn-primary" type="button">Scroll to it</button>
    <button id="demo-big-top" class="btn btn-sm btn-outline" type="button">Top</button>
    <span class="text-sm text-gray-600">Showing <span id="demo-big-range" class="sx-vl-output">-</span></span>
</div>
<div id="demo-big" class="sx-vl mb-4"></div>
/**
 * 100,000 fixed-height rows - only the rows on screen exist in the page.
 */
export function mount(root) {
    const rows = Array.from({length: 100000}, (_v, i) => ({id: i, name: 'Customer ' + i, total: (i * 7919) % 10000}));

    const big = E.virtualList($(root).find('#demo-big').get(0), {
        items: rows,
        itemHeight: 40,
        keyField: 'id',
        label: 'Customers',
        render: (r) => '<span class="sx-vl-num">#' + r.id + '</span>' +
            '<span class="sx-vl-grow">' + _.escape(r.name) + '</span>' +
            '<span class="sx-vl-amount">£' + (r.total / 100).toFixed(2) + '</span>',
        onItemClick: (r) => E.toast('Clicked ' + r.name, {type: 'info'})
    });

    // The rows on screen, and how many row elements the page holds.
    const show = () => {
        const {start, end} = big.getVisibleRange();
        $(root).find('#demo-big-range').text(start + ' - ' + end);
        $(root).find('#demo-big-dom').text(String($(root).find('.dm-vlist-row').length));
    };
    big.on('scroll', show);
    big.on('render', show);
    show();

    $(root).find('#demo-big-go').on('click', () => {
        big.scrollToIndex(Number($(root).find('#demo-big-index').val()) || 0, {align: 'center'});
    });
    $(root).find('#demo-big-top').on('click', () => big.scrollToIndex(0, {align: 'start'}));

    return () => big.destroy();
}
/* Rows are the list's own .dm-vlist-row elements. */
.sx-vl .dm-vlist-row {
    display: flex;
    align-items: center;
    gap: var(--dm-space-3);
    padding: 0 var(--dm-space-4);
    border-bottom: 1px solid var(--dm-border);
    cursor: pointer;
}

.sx-vl .dm-vlist-row:hover {
    background: var(--dm-hover-bg);
}
const rows = Array.from({length: 100000}, (_, i) => ({id: i, name: 'Customer ' + i, total: (i * 7919) % 10000}));

const big = E.virtualList('#demo-big', {
    items: rows,
    itemHeight: 40,
    keyField: 'id',
    label: 'Customers',
    render: (r) => `<span class="sx-vl-num">#${r.id}</span>
                   <span class="sx-vl-grow">${_.escape(r.name)}</span>
                   <span class="sx-vl-amount">£${(r.total / 100).toFixed(2)}</span>`,
    onItemClick: (r) => E.toast('Clicked ' + r.name)
});

$('#demo-big-go').on('click', () => big.scrollToIndex(Number($('#demo-big-index').val()), {align: 'center'}));
big.on('scroll', ({start, end}) => $('#demo-big-range').text(start + ' - ' + end));

Rows of different heights

With itemHeight: 'auto' each row is measured once it is rendered (and again if it changes size), and rows not yet seen count as estimatedItemHeight. The heights are cached by key. "Load older" prepends twenty messages: the one you are reading stays where it is.

Rows of different heights
0 messages
<div class="sx-vl-bar mb-3">
    <button id="demo-chat-older" class="btn btn-sm btn-outline" type="button">Load older</button>
    <button id="demo-chat-bottom" class="btn btn-sm btn-outline" type="button">Latest</button>
    <span class="text-sm text-gray-600"><span id="demo-chat-count" class="sx-vl-output">0</span> messages</span>
</div>
<div id="demo-chat" class="sx-vl mb-4"></div>
/**
 * Rows of different heights - measured rows, a chat that opens at the bottom and grows upwards.
 */
export function mount(root) {
    // A small seeded generator, so every visit shows the same messages.
    let seed = 7;
    const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
    const pick = (list) => list[Math.floor(rand() * list.length)];
    const WORDS = ('lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut ' +
        'labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris').split(' ');
    const sentence = (min, max) => {
        const n = min + Math.floor(rand() * (max - min));
        const text = Array.from({length: n}, () => pick(WORDS)).join(' ');
        return text.charAt(0).toUpperCase() + text.slice(1) + '.';
    };

    const PEOPLE = ['Ada', 'Grace', 'Alan', 'Katherine', 'Tim', 'Margaret'];
    let messageId = 0;
    let minutesAgo = 0;
    const message = () => {
        minutesAgo += 1 + Math.floor(rand() * 9);
        return {
            id: 'm' + (messageId++),
            who: pick(PEOPLE),
            when: D().subtract(minutesAgo, 'minutes').format('D MMM HH:mm'),
            text: sentence(3, rand() < 0.2 ? 90 : 25)
        };
    };
    // Generated newest first, shown oldest first.
    const olderMessages = (n) => Array.from({length: n}, message).reverse();
    const messages = olderMessages(5000);

    const chat = E.virtualList($(root).find('#demo-chat').get(0), {
        items: messages,
        itemHeight: 'auto',
        estimatedItemHeight: 72,
        keyField: 'id',
        rowClass: 'sx-vl-msg',
        label: 'Messages',
        render: (m) => '<span class="sx-vl-who">' + _.escape(m.who) + '</span>' +
            '<span class="sx-vl-when">' + _.escape(m.when) + '</span>' +
            '<p class="sx-vl-text">' + _.escape(m.text) + '</p>',
        onItemClick: (m) => E.toast(m.who + ' at ' + m.when, {type: 'info'})
    });
    const latest = () => chat.scrollToIndex(chat.count - 1, {align: 'end'});
    const showCount = () => $(root).find('#demo-chat-count').text(String(chat.count));
    latest();
    showCount();

    $(root).find('#demo-chat-older').on('click', () => {
        chat.prepend(olderMessages(20));   // the message you are reading stays put
        showCount();
    });
    $(root).find('#demo-chat-bottom').on('click', latest);

    return () => chat.destroy();
}
/* rowClass adds .sx-vl-msg to every row. */
.sx-vl .dm-vlist-row {
    display: flex;
    align-items: center;
    gap: var(--dm-space-3);
    padding: 0 var(--dm-space-4);
    border-bottom: 1px solid var(--dm-border);
    cursor: pointer;
}

.sx-vl .dm-vlist-row:hover {
    background: var(--dm-hover-bg);
}

.sx-vl-msg.dm-vlist-row {
    display: block;
    padding: var(--dm-space-3) var(--dm-space-4);
}
const chat = E.virtualList('#demo-chat', {
    items: messages,
    itemHeight: 'auto',
    estimatedItemHeight: 72,
    keyField: 'id',
    rowClass: 'sx-vl-msg',
    label: 'Messages',
    render: (m) => `<span class="sx-vl-who">${_.escape(m.who)}</span>
                   <span class="sx-vl-when">${_.escape(m.when)}</span>
                   <p class="sx-vl-text">${_.escape(m.text)}</p>`
});
chat.scrollToIndex(messages.length - 1, {align: 'end'});

$('#demo-chat-older').on('click', () => chat.prepend(olderMessages(20)));
$('#demo-chat-bottom').on('click', () => chat.scrollToIndex(chat.count - 1, {align: 'end'}));

Infinite feed

loadMore() is called whenever the end is within threshold px. It returns a promise of the next items - or of {items, done}. Only one load runs at a time; an empty page or done: true ends the list. This fake source takes half a second a page, fails once on page three (press Retry) and stops after eight pages.

Infinite feed
<div id="demo-feed" class="sx-vl mb-4"></div>
/**
 * Infinite feed - loadMore() pages in posts; page three fails once, and the feed ends after eight.
 */
export function mount(root) {
    let seed = 7;
    const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
    const pick = (list) => list[Math.floor(rand() * list.length)];
    const WORDS = ('lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut ' +
        'labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris').split(' ');
    const sentence = (min, max) => {
        const n = min + Math.floor(rand() * (max - min));
        const text = Array.from({length: n}, () => pick(WORDS)).join(' ');
        return text.charAt(0).toUpperCase() + text.slice(1) + '.';
    };

    // A pretend server: half a second a page.
    const timers = new Set();
    let failedOnce = false;
    let postId = 0;
    const fetchPage = (page) => new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
            timers.delete(timer);
            if (page === 2 && !failedOnce) {
                failedOnce = true;
                reject(new Error('The network went away (on purpose). Press Retry.'));
                return;
            }
            const items = Array.from({length: 20}, () => ({
                id: 'p' + (++postId),
                title: 'Post ' + postId + ': ' + sentence(3, 8),
                body: sentence(10, rand() < 0.3 ? 60 : 24)
            }));
            resolve({items, done: page >= 7});
        }, 500);
        timers.add(timer);
    });

    const feed = E.virtualList($(root).find('#demo-feed').get(0), {
        itemHeight: 'auto',
        estimatedItemHeight: 96,
        keyField: 'id',
        rowClass: 'sx-vl-post',
        label: 'Posts',
        threshold: 300,
        loadMore: ({page}) => fetchPage(page),
        endTemplate: 'You are all caught up.',
        render: (p) => '<p class="sx-vl-post-title">' + _.escape(p.title) + '</p>' +
            '<p class="sx-vl-post-body">' + _.escape(p.body) + '</p>',
        onItemClick: (p) => E.toast('Opened ' + p.title, {type: 'info'})
    });

    return () => {
        timers.forEach(clearTimeout);
        feed.destroy();
    };
}
/* rowClass adds .sx-vl-post to every row. */
.sx-vl .dm-vlist-row {
    display: flex;
    align-items: center;
    gap: var(--dm-space-3);
    padding: 0 var(--dm-space-4);
    border-bottom: 1px solid var(--dm-border);
    cursor: pointer;
}

.sx-vl .dm-vlist-row:hover {
    background: var(--dm-hover-bg);
}

.sx-vl-post.dm-vlist-row {
    display: block;
    padding: var(--dm-space-4);
}
const feed = E.virtualList('#demo-feed', {
    itemHeight: 'auto',
    estimatedItemHeight: 96,
    keyField: 'id',
    rowClass: 'sx-vl-post',
    label: 'Posts',
    threshold: 300,
    loadMore: ({page}) => fetchPage(page),      // H.get('/api/posts?page=' + page) in real life
    endTemplate: 'You are all caught up.',
    render: (p) => `<p class="sx-vl-post-title">${_.escape(p.title)}</p>
                   <p class="sx-vl-post-body">${_.escape(p.body)}</p>`
});
// a failed page shows the error row; its Retry button calls feed.retry()

Events

Every event is an onX option, an .on('x') listener and a bubbling dm:virtual-list:x DOM event (detail = {instance, args}). This log is one document listener: it hears the feed loading and clicks on any of the lists above.

Events
  • Nothing yet - scroll the feed or click a row.
<ul id="demo-log" class="list-none p-0 text-sm text-gray-600 mb-4">
    <li>Nothing yet - scroll the feed or click a row.</li>
</ul>
/**
 * Events - one document listener logs the lists on this page (the feed's loading, and clicks on any row).
 */
export function mount(root) {
    const list = $(root).find('#demo-log');
    const EVENTS = 'dm:virtual-list:loadmore dm:virtual-list:loaded dm:virtual-list:end ' +
        'dm:virtual-list:error dm:virtual-list:itemclick';

    const log = (message) => {
        list.find('li').filter((i, el) => /^Nothing yet/.test(el.textContent)).remove();
        list.prepend($('<li>').text(D().format('HH:mm:ss') + ' - ' + message));
        list.find('li').filter((i) => i >= 8).remove();
    };

    // detail = {instance, args}: the list that fired, and the event's arguments.
    const describe = {
        loadmore: ({page}) => 'loading page ' + (page + 1),
        loaded: (items) => 'loaded ' + items.length + ' items',
        end: () => 'nothing more to load',
        error: (err) => 'error: ' + err.message,
        itemclick: (item, index) => 'clicked row ' + index
    };
    const onEvent = (e) => {
        const name = e.type.split(':').pop();
        const label = e.detail.instance.options.label || 'A list';
        log(label + ': ' + describe[name](...e.detail.args));
    };

    $(document).on(EVENTS, onEvent);
    return () => $(document).off(EVENTS, onEvent);
}
const EVENTS = 'dm:virtual-list:loadmore dm:virtual-list:loaded dm:virtual-list:end ' +
    'dm:virtual-list:error dm:virtual-list:itemclick';
const onEvent = (e) => {
    const {instance} = e.detail;          // detail = {instance, args}
    log(instance.options.label + ': ' + e.type.split(':').pop());
};
$(document).on(EVENTS, onEvent);
// teardown: $(document).off(EVENTS, onEvent)

feed.on('error', (err) => console.warn(err.message));   // or on one list
Event Arguments When
render ({start, end}) The rendered range (overscan included) changed
scroll ({start, end}) Once per frame while scrolling: the rows on screen
loadmore ({count, page}) A loadMore() call starts
loaded (items) A page arrived
end - loadMore() said there is nothing more
error (error) loadMore() failed; the retry row shows
itemclick (item, index, event) A row was clicked, or Enter pressed on it

Keyboard and screen readers

The rows sit in a role="list" and are role="listitem" with aria-posinset and aria-setsize (-1 while an infinite list can still grow), so a screen reader announces "row 50,001 of 100,000" even though only a handful exist. The list is one tab stop (roving tabindex). The focused row stays rendered when you scroll it out of view, so focus is never dropped onto the page.

Key Does
↑ ↓ Previous / next row
Page Up Page Down A screenful up / down
Home End First / last row
Enter Fires itemclick

Options

Option Type Default Description
items array [] The data
count + getItem number, function - Instead of items: rows served on demand by getItem(index)
itemHeight number | 'auto' 40 Fixed px height, or measure each row
estimatedItemHeight number 40 Height assumed for rows not yet measured
overscan number 4 Rows rendered beyond each edge
render function the item as text (item, index) → HTML string or Element
safe boolean true false inserts render() strings without sanitising
keyField string | function the index A stable key per item; rows and measured heights follow it
label string null The list's aria-label
rowClass string '' Extra class on every row
loadMore function null ({count, page}) → Promise of items[] or {items, done}
threshold number 200 px from the end at which to load
loadingTemplate / endTemplate / errorTemplate string | Element | function built in / none / built in The status row; a [data-vlist-retry] element in it retries

Methods

Method Description
setItems(items) Replace the data (and reset infinite loading)
append(items) / prepend(items) Add at the end / at the start without moving the view
update(indexOrKey, item) Replace one item; a number is an index, anything else a key
indexOfKey(key) The index of an item by key
scrollToIndex(i, {align, smooth}) align: start, center, end or auto (only if out of view)
focusIndex(i) Scroll to a row and focus it
getVisibleRange() / getRenderedRange() {start, end}, inclusive
refresh() Re-render (and re-measure) the rendered rows
retry() / isDone() Retry a failed load / whether loading has ended
count The number of rows (a property)
setCount(n) Count mode: change the number of rows
destroy() Remove the rows and listeners and give the container its own children back