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