Skip to content
Sveltier
Mode
Primitives

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.

1 Salary +$3,200.00
2 Atlas Insurance -$80.19
3 Harbor Rent -$159.38
4 Northwind Grocers -$238.57
5 Streamline Media -$77.76
6 City Utilities -$156.95
7 Metro Transit -$236.14
8 Coffee Collective -$75.33
9 Atlas Insurance -$154.52
10 Harbor Rent -$233.71
11 Northwind Grocers -$72.90
12 Streamline Media -$152.09
13 City Utilities -$231.28
14 Metro Transit -$70.47
15 Coffee Collective -$149.66

100,000 transactions. Only the rows in view, and three more on each side, are in the page.

Ledger.svelte
<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.

  1. Payment approved.
  2. Signed in from a new device.
  3. September statement ready to download.
  4. Card ending 4471 frozen after three declined attempts in a row.
  5. Direct debit set up for City Utilities, monthly, with the first collection on the 1st.
  6. Two-step verification turned on for this account and for every device signed in to it, including the two tablets.
  7. Transfer to Alex Morgan released after a second review of the counterparty documents and the source of funds statement.
  8. Limit raised.
  9. Payment approved.
  10. 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.

ActivityLog.svelte
<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.

CurrencyList.svelte
<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

NameTypeDefaultDescription
countnumber–How many items the list has.
itemSizenumber | ((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.
overscannumber3Items rendered beyond each edge of the viewport, so fast scrolling shows no gaps.
key(index: number) => string | numberthe indexA 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.
ssrHeightnumber0Viewport height in px assumed until the viewport is measured, as on the server.
NameTypeDescription
itemsVirtualItem[]The items to render, each with index, key, and start, size and end in px from the top of the list.
totalSizenumberHeight of all the items in px, for the element that holds them.
viewportAttachment<HTMLElement>For the scrolling element. Turns off the browser's scroll anchoring there: the list keeps items steady itself.
measureAttachment<HTMLElement>For items whose height is measured. They need data-index={item.index}.
scrollToIndex(index, options)(index: number, options?: { align?: 'auto' | 'start' | 'center' | 'end' }) => voidScrolls 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.