VirtualList

Render a large array while mounting only visible rows plus a small overscan. Rows use a fixed item_size. Data fetching stays in the caller.

Minimal example

Set a viewport height and render a row through children(item, idx). The index is its position in the complete array, not its position within the visible window.

1: Item 1
2: Item 2
3: Item 3
4: Item 4
5: Item 5
6: Item 6
7: Item 7
8: Item 8
9: Item 9
10: Item 10
11: Item 11
12: Item 12
13: Item 13
14: Item 14
15: Item 15
16: Item 16
17: Item 17
18: Item 18
19: Item 19
20: Item 20
svelte<script lang="ts">
  import { VirtualList } from 'svelte-widgets'

  const items = Array.from({ length: 10000 }, (_value, idx) => `Item ${idx + 1}`)
</script>

<VirtualList
  {items}
  item_size={32}
  style="height: 192px"
  tabindex="0"
  aria-label="Ten thousand items"
>
  {#snippet children(item, idx)}
    <div style="height: 32px; display: flex; align-items: center">{idx + 1}: {item}</div>
  {/snippet}
</VirtualList>

Filtering and jumping to a row

Use stable keys when filtering or reordering items. scroll_to_index reveals a zero-based index, clamped to the available range. Reset to the first row after changing a filter so a prior scroll position cannot hide a shorter result set.

10000 results

Record 1
Record 2
Record 3
Record 4
Record 5
Record 6
Record 7
Record 8
Record 9
Record 10
Record 11
Record 12
Record 13
Record 14
Record 15
Record 16
Record 17
Record 18
Record 19
Record 20
svelte<script lang="ts">
  import { tick } from 'svelte'
  import { VirtualList } from 'svelte-widgets'

  const items = Array.from({ length: 10000 }, (_value, idx) => ({
    id: idx,
    name: `Record ${idx + 1}`,
  }))
  let query = $state(``)
  let list = $state<{ scroll_to_index: (idx: number) => void }>()
  const filtered = $derived(items.filter((item) => item.name.includes(query)))
</script>

<label
  >Filter records <input
    bind:value={query}
    oninput={async () => {
      await tick()
      list?.scroll_to_index(0)
    }}
  /></label
>
<button
  disabled={!filtered.length}
  onclick={() => list?.scroll_to_index(filtered.length - 1)}>Jump to last result</button
>
<p>{filtered.length} results</p>
{#if filtered.length}
  <VirtualList
    bind:this={list}
    items={filtered}
    key={(item) => item.id}
    item_size={32}
    style="height: 192px"
    tabindex="0"
    aria-label="Filtered records"
  >
    {#snippet children(item)}
      <div style="height: 32px; display: flex; align-items: center">{item.name}</div>
    {/snippet}
  </VirtualList>
{:else}
  <p role="status">No records match your filter.</p>
{/if}

Main API

Prop / methodPurpose
items: readonly Item[]Complete array available for scrolling.
children(item, idx)Render each mounted row. Each row must have the configured fixed height.
item_size={32}Positive fixed row height in pixels.
overscan={5}Extra rows mounted above and below the visible window.
initial_count={20}Initial render window before the viewport is measured, including server rendering.
key(item, idx)Stable row key; defaults to the index.
bind:elementAccess the scrolling div for DOM integration.
scroll_to_index(idx)Component method exposed through bind:this.

The container defaults to a maximum height of 20rem. For a taller viewport, override both height and max-height, for example with style="height: 40rem; max-height: none". virtual_window from svelte-widgets/virtual exposes the same window calculation for custom table/grid markup. Fetching, request errors and loading indicators belong outside the list; use MultiSelect loading recipes for paged option loading.

Keyboard and accessibility

tabindex="0" in these examples makes the scroll area keyboard-focusable for native arrow and Page Up/Down scrolling. VirtualList does not implement row selection or roving focus. Only mounted rows exist in the accessibility tree and browser find results; provide filtering or a non-virtualized alternative when users need to inspect every record at once. Keep application state outside row snippets because offscreen rows unmount.