Virtual lists
Scroll through hundreds of thousands of rows while only the ones in view exist in the page.
Rows of one height
virtualList() works out which items are in view from the scroll position, and where
each one goes. Attach viewport to the scrolling element, give the element inside it
the list's totalSize, and place each item at its start. When every
item is as tall, finding them costs the same for ten rows or a million.
100,000 transactions. Only the rows in view, and three more on each side, are in the page.
<script>
import { virtualList } from 'sveltier';
let { transactions } = $props();
// Read through a function, so the list follows transactions.length.
const list = virtualList(() => ({ count: transactions.length, itemSize: 34 }));
</script>
<div class="viewport" {@attach list.viewport}>
<div style:height="{list.totalSize}px" style:position="relative">
{#each list.items as item (item.key)}
<div class="row" style:transform="translateY({item.start}px)">
{transactions[item.index].label}
</div>
{/each}
</div>
</div>
<style>
.viewport { height: 377px; overflow-y: auto; }
.row { position: absolute; inset-inline: 0; height: 34px; }
</style>Rows measured after layout
When heights depend on the content and the width, give an estimate and attach measure to each item, with data-index. Items are measured once laid
out and again whenever their size changes. When an item above the scroll position changes
height, the list moves with it, so what you are reading stays still. A key keeps
measurements with their items when the list is sorted or grows at the top; it is read for every
item when sizes change, so keep large data in $state.raw.
- Payment approved.
- Signed in from a new device.
- September statement ready to download.
- Card ending 4471 frozen after three declined attempts in a row.
- Direct debit set up for City Utilities, monthly, with the first collection on the 1st.
- Two-step verification turned on for this account and for every device signed in to it, including the two tablets.
- Transfer to Alex Morgan released after a second review of the counterparty documents and the source of funds statement.
- Limit raised.
- Payment approved.
- Signed in from a new device.
Entries wrap to the width of the page, so their heights are measured, not set. Scroll up from the end and the entry you are reading stays still as the ones above it are measured.
<script>
import { virtualList } from 'sveltier';
let { entries } = $props();
const list = virtualList(() => ({
count: entries.length,
itemSize: 55, // an estimate: each entry is measured once laid out
key: (index) => entries[index].id
}));
</script>
<div class="viewport" {@attach list.viewport}>
<ol style:height="{list.totalSize}px" style:position="relative">
{#each list.items as item (item.key)}
<li
data-index={item.index}
style:position="absolute"
style:transform="translateY({item.start}px)"
{@attach list.measure}
>
{entries[item.index].text}
</li>
{/each}
</ol>
</div>Accessibility
Only the rendered items exist, so tell assistive technology about the rest: aria-setsize and aria-posinset on list items and options, aria-rowcount and aria-rowindex in tables and grids.
Items scrolled out of view are removed, along with any focus inside them. In a listbox or
combobox, keep focus on the input or the listbox and point at the active option with aria-activedescendant. scrollToIndex() renders its target at once, so
the reference always resolves. When nothing inside the scrolling element can take focus, give it tabindex="0", a role and a label, so keyboard users can scroll it.
<script>
import { keymap, virtualList } from 'sveltier';
let { options } = $props();
const id = $props.id();
let active = $state(0);
const list = virtualList(() => ({ count: options.length, itemSize: 34 }));
// Focus stays on the listbox; scrollToIndex renders the new active option at once.
const move = (by) => () => {
active = Math.max(0, Math.min(options.length - 1, active + by));
list.scrollToIndex(active);
};
</script>
<div
role="listbox"
tabindex="0"
aria-label="Currency"
aria-activedescendant="{id}-{active}"
{@attach list.viewport}
{@attach keymap({ ArrowDown: move(1), ArrowUp: move(-1) })}
>
<div style:height="{list.totalSize}px" style:position="relative">
{#each list.items as item (item.key)}
<div
id="{id}-{item.index}"
role="option"
aria-selected={item.index === active}
aria-setsize={options.length}
aria-posinset={item.index + 1}
style:position="absolute"
style:transform="translateY({item.start}px)"
>
{options[item.index]}
</div>
{/each}
</div>
</div>Server rendering
The server cannot measure the viewport, so it renders the items that fit in ssrHeight. It is 0 by default, which renders only the overscan; set it to the
height the list will have and the page arrives with its first rows in place.
API
| Name | Type | Default | Description |
|---|---|---|---|
count | number | – | How many items the list has. |
itemSize | number | ((index: number) => number) | – | Item height in px: one number when every item is as tall, or an estimate per index. Measured items use their measurement. |
overscan | number | 3 | Items rendered beyond each edge of the viewport, so fast scrolling shows no gaps. |
key | (index: number) => string | number | the index | A stable key per item, so measured heights follow items that move. It is read for every item when measurements change, so keep large data in $state.raw. |
ssrHeight | number | 0 | Viewport height in px assumed until the viewport is measured, as on the server. |
| Name | Type | Description |
|---|---|---|
items | VirtualItem[] | The items to render, each with index, key, and start, size and end in px from the top of the list. |
totalSize | number | Height of all the items in px, for the element that holds them. |
viewport | Attachment<HTMLElement> | For the scrolling element. Turns off the browser's scroll anchoring there: the list keeps items steady itself. |
measure | Attachment<HTMLElement> | For items whose height is measured. They need data-index={item.index}. |
scrollToIndex(index, options) | (index: number, options?: { align?: 'auto' | 'start' | 'center' | 'end' }) => void | Scrolls the item into view at once and renders it in the same update, then keeps it in place while the rows around it are measured. auto moves as little as possible, and not at all when the item is in view. Indexes outside the list are ignored. |