Skip to content
omaris

Data Display

Virtual List

A windowed list: ten thousand rows, and only the thirty on screen exist.

import { VirtualList } from 'omaris'
Learn

Examples

Basic

Ten thousand rows of uneven height. Only the ones on screen exist; each is measured as it renders, and estimateSize only has to be close.

Amina Yusuf

Amina Yusuf

#1

Pushed to main.

Dilan Karim

Dilan Karim

#2

Commented: looks good, one nit about the retry backoff.

Hevi Salih

Hevi Salih

#3

Merged.

Omar Nasir

Omar Nasir

#4

Opened a pull request against the billing service that moves invoices onto the new queue.

Sara Kamal

Sara Kamal

#5

Closed the issue as fixed in the last deploy, after checking the error rate had dropped back to its usual level on every region.

Amina Yusuf

Amina Yusuf

#6

Pushed to main.

Dilan Karim

Dilan Karim

#7

Commented: looks good, one nit about the retry backoff.

Hevi Salih

Hevi Salih

#8

Merged.

Omar Nasir

Omar Nasir

#9

Opened a pull request against the billing service that moves invoices onto the new queue.

Sara Kamal

Sara Kamal

#10

Closed the issue as fixed in the last deploy, after checking the error rate had dropped back to its usual level on every region.

Amina Yusuf

Amina Yusuf

#11

Pushed to main.

Dilan Karim

Dilan Karim

#12

Commented: looks good, one nit about the retry backoff.

Hevi Salih

Hevi Salih

#13

Merged.

Omar Nasir

Omar Nasir

#14

Opened a pull request against the billing service that moves invoices onto the new queue.

Sara Kamal

Sara Kamal

#15

Closed the issue as fixed in the last deploy, after checking the error rate had dropped back to its usual level on every region.

Amina Yusuf

Amina Yusuf

#16

Pushed to main.

Dilan Karim

Dilan Karim

#17

Commented: looks good, one nit about the retry backoff.

<script lang="ts">	import { Avatar, Text, VirtualList } from 'omaris';​	const PEOPLE = ['Amina Yusuf', 'Dilan Karim', 'Hevi Salih', 'Omar Nasir', 'Sara Kamal'];	const LINES = [		'Pushed to main.',		'Opened a pull request against the billing service that moves invoices onto the new queue.',		'Commented: looks good, one nit about the retry backoff.',		'Closed the issue as fixed in the last deploy, after checking the error rate had dropped back to its usual level on every region.',		'Merged.'	];​	const messages = Array.from({ length: 10_000 }, (_, i) => ({		id: i,		who: PEOPLE[i % PEOPLE.length],		text: LINES[(i * 7) % LINES.length]	}));</script>​<VirtualList	items={messages}	estimateSize={72}	getKey={(message) => message.id}	height={400}	aria-label="Activity"	class="rounded-shape-lg border border-border">	{#snippet children(message, index)}		<div class="flex gap-3 border-b border-border px-4 py-3">			<Avatar name={message.who} size="sm" colorize />			<div class="min-w-0">				<div class="flex items-baseline gap-2">					<Text variant="title-sm">{message.who}</Text>					<Text variant="label-sm" tone="muted" tabular>#{index + 1}</Text>				</div>				<Text variant="body-sm" tone="muted">{message.text}</Text>			</div>		</div>	{/snippet}</VirtualList>

Infinite scroll

onend fires once as the end comes within endThreshold, and again only after items grows. loading is drawn after the last row while there is more to fetch — pass undefined once there is not.

Order 1 · Zagros Café

12,000 IQD

Order 2 · Nishtiman Books

15,250 IQD

Order 3 · Erbil Bakery

18,500 IQD

Order 4 · Tigris Tailors

21,750 IQD

Order 5 · Zagros Café

25,000 IQD

Order 6 · Nishtiman Books

28,250 IQD

Order 7 · Erbil Bakery

31,500 IQD

Order 8 · Tigris Tailors

34,750 IQD

Order 9 · Zagros Café

38,000 IQD

Order 10 · Nishtiman Books

41,250 IQD

Order 11 · Erbil Bakery

44,500 IQD

Order 12 · Tigris Tailors

47,750 IQD

Order 13 · Zagros Café

51,000 IQD

Order 14 · Nishtiman Books

54,250 IQD

Order 15 · Erbil Bakery

57,500 IQD

Order 16 · Tigris Tailors

60,750 IQD

Order 17 · Zagros Café

64,000 IQD

Order 18 · Nishtiman Books

67,250 IQD

Order 19 · Erbil Bakery

70,500 IQD
<script lang="ts">	import { CircularProgress, Text, VirtualList, latn } from 'omaris';​	const PAGE = 30;	const LAST = 300;​	type Order = { id: number; customer: string; total: number };	const CUSTOMERS = ['Zagros Café', 'Nishtiman Books', 'Erbil Bakery', 'Tigris Tailors'];​	const page = (from: number): Order[] =>		Array.from({ length: PAGE }, (_, i) => ({			id: from + i + 1,			customer: CUSTOMERS[(from + i) % CUSTOMERS.length],			total: 12_000 + (((from + i) * 3_250) % 90_000)		}));​	let orders = $state<Order[]>(page(0));	let fetching = false;​	function more() {		if (fetching || orders.length >= LAST) return;		fetching = true;		setTimeout(() => {			orders = [...orders, ...page(orders.length)];			fetching = false;		}, 700);	}​	const iqd = new Intl.NumberFormat('en', latn({ maximumFractionDigits: 0 }));</script>​<VirtualList	items={orders}	estimateSize={56}	getKey={(order) => order.id}	height={360}	onend={more}	loading={orders.length < LAST ? spinner : undefined}	aria-label="Orders"	class="rounded-shape-lg border border-border">	{#snippet children(order)}		<div class="flex items-center justify-between gap-4 border-b border-border px-4 py-3">			<Text variant="body-md" lines={1}>Order {order.id} · {order.customer}</Text>			<Text variant="label-md" tabular>{iqd.format(order.total)} IQD</Text>		</div>	{/snippet}</VirtualList>​{#snippet spinner()}	<div class="grid place-items-center py-4">		<CircularProgress size="sm" label="Loading more orders" />	</div>{/snippet}

Scroll to index

bind:this gives you scrollToIndex(i, { align, behavior }). The rows on the way have never been measured, so it lands on the estimate and then corrects itself as the destination reports its real size.

Chapter 1

Line 2 of the manuscript.

Line 3 of the manuscript.

Line 4 of the manuscript.

Line 5 of the manuscript.

Line 6 of the manuscript.

Line 7 of the manuscript.

Line 8 of the manuscript.

Line 9 of the manuscript.

Chapter 2

Line 11 of the manuscript.

Line 12 of the manuscript.

Line 13 of the manuscript.

Line 14 of the manuscript.

Line 15 of the manuscript.

Line 16 of the manuscript.

Line 17 of the manuscript.

Line 18 of the manuscript.

Chapter 3

Line 20 of the manuscript.

Line 21 of the manuscript.

Line 22 of the manuscript.

Line 23 of the manuscript.

Line 24 of the manuscript.

Line 25 of the manuscript.

<script lang="ts">	import { Button, NumberInput, Text, VirtualList } from 'omaris';​	const lines = Array.from({ length: 10_000 }, (_, i) => ({		id: i,		text: i % 9 === 0 ? `Chapter ${i / 9 + 1}` : `Line ${i + 1} of the manuscript.`	}));​	let list = $state<ReturnType<typeof VirtualList<(typeof lines)[number]>> | null>(null);	let target = $state(5000);</script>​<div class="flex w-full flex-col gap-3">	<div class="flex flex-wrap items-end gap-2">		<NumberInput label="Row" bind:value={target} min={1} max={10_000} class="w-36" />		<Button			variant="tonal"			onclick={() => list?.scrollToIndex(target - 1, { align: 'center', behavior: 'smooth' })}		>			Go		</Button>	</div>​	<VirtualList		bind:this={list}		items={lines}		estimateSize={(index) => (index % 9 === 0 ? 56 : 36)}		getKey={(line) => line.id}		height={320}		aria-label="Manuscript"		class="rounded-shape-lg border border-border"	>		{#snippet children(line, index)}			{#if index % 9 === 0}				<Text variant="title-md" class="bg-surface-container-low px-4 pt-5 pb-2">{line.text}</Text>			{:else}				<Text variant="body-md" class="px-4 py-1.5">{line.text}</Text>			{/if}		{/snippet}	</VirtualList></div>

Overridden

The height comes from the parent here — no height at all — and classes reaches the scroller, the window and each row's wrapper. horizontal lays the same machinery on its side.

Thu, Jan 1

0 bookings

Fri, Jan 2

4 bookings

Sat, Jan 3

8 bookings

Sun, Jan 4

1 bookings

Mon, Jan 5

5 bookings

Tue, Jan 6

9 bookings

Wed, Jan 7

2 bookings

Thu, Jan 8

6 bookings

Fri, Jan 9

10 bookings

Sat, Jan 10

3 bookings

Sun, Jan 11

7 bookings

Mon, Jan 12

0 bookings

Tue, Jan 13

4 bookings

Wed, Jan 14

8 bookings

Thu, Jan 15

1 bookings

Fri, Jan 16

5 bookings

Sat, Jan 17

9 bookings

Sun, Jan 18

2 bookings

Mon, Jan 19

6 bookings

Tue, Jan 20

10 bookings

Wed, Jan 21

3 bookings

Thu, Jan 22

7 bookings

Fri, Jan 23

0 bookings
Thu, Jan 1

0

Fri, Jan 2

4

Sat, Jan 3

8

Sun, Jan 4

1

Mon, Jan 5

5

Tue, Jan 6

9

Wed, Jan 7

2

Thu, Jan 8

6

Fri, Jan 9

10

Sat, Jan 10

3

Sun, Jan 11

7

Mon, Jan 12

0

Tue, Jan 13

4

Wed, Jan 14

8

<script lang="ts">	import { Text, VirtualList, latn } from 'omaris';​	const format = new Intl.DateTimeFormat(		'en',		latn({ weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })	);​	const days = Array.from({ length: 3650 }, (_, i) => {		const date = new Date(Date.UTC(2026, 0, 1 + i));		return {			id: i,			label: format.format(date),			busy: (i * 37) % 11		};	});</script>​<div class="flex w-full flex-col gap-4">	<div class="h-56 rounded-shape-lg border border-border">		<VirtualList			items={days}			estimateSize={44}			getKey={(day) => day.id}			aria-label="Days"			classes={{ root: 'rounded-shape-lg', item: 'odd:bg-surface-container-low' }}		>			{#snippet children(day)}				<div class="flex items-center justify-between px-4 py-2.5">					<Text variant="body-md">{day.label}</Text>					<Text variant="label-md" tone="muted" tabular>{day.busy} bookings</Text>				</div>			{/snippet}		</VirtualList>	</div>​	<VirtualList		horizontal		items={days}		estimateSize={88}		gap={8}		getKey={(day) => day.id}		aria-label="Days, side by side"		classes={{ root: 'pb-2' }}	>		{#snippet children(day)}			<div class="flex w-22 flex-col gap-1 rounded-shape-md bg-surface-container p-3">				<Text variant="label-sm" tone="muted">{day.label}</Text>				<Text variant="title-md" tabular>{day.busy}</Text>			</div>		{/snippet}	</VirtualList></div>

When to use it

Use it for

  • A list long enough that rendering all of it is the slow part — a thousand rows and up: an activity feed, a message history, search results, a picker over every customer.
  • Rows of uneven height. Each is measured as it renders and cached by getKey, so wrapped text and late-loading images are fine.
  • Infinite scroll: onend to fetch the next page, loading drawn after the last row while there is more.
  • Jumping to a row from outside — a search hit, "back to where I was" — with bind:this and scrollToIndex(i, { align: 'center' }).
  • A long horizontal strip of cards with horizontal.

Not for

  • A list of a few dozen → List. Windowing costs find-in-page, anchor links and focus that survives scrolling, and buys nothing under a few hundred rows.
  • Rows and columns that sort, filter and select → Table.
  • Log output with levels, search and a tail that follows → Log Viewer, which is already virtualised.
  • A photo wall of unequal cards in columns → Masonry.
  • Paging through results a person should be able to link to → Pagination.

Do

  • Give it a height: a parent with one (it fills it), or height. Without either it stops at the height of the screen.
  • Pass getKey whenever items can be inserted, removed or re-sorted, so the measured sizes follow the rows rather than the positions.
  • Set estimateSize to a typical row. It only has to be close; a function of the index is there for lists whose rows come in known kinds.
  • Give it an aria-label — it is a list with a name, and every row carries aria-setsize and aria-posinset, so a screen reader still hears "row 512 of 10,000".
  • Put the row's padding and border inside the snippet; the wrapper is what gets measured.

Don't

  • Rely on focus staying in a row that is scrolled far away — rows off screen do not exist. Keep the thing being edited in a panel or a Dialog, not in the row.
  • Set a margin on the row's outer element; margins are outside the measured box. Use gap.
  • Nest it inside another scroll container of the same axis. It is the scroller.
  • Fire a fetch from onend without guarding against one already in flight — it re-arms when items grows, not when your request finishes.

API

VirtualList

A windowed list: ten thousand rows, and only the thirty on screen exist.

The canvas is sized to the whole list, so the scrollbar tells the truth about how much there is, and the visible slice is one block translated down to where it belongs. Scrolling moves one translate and swaps a handful of keyed rows at the edges — nothing is laid out that is not on screen, which is what keeps it at 60fps on a phone.

Rows can be any height. Each one is measured by a single shared ResizeObserver the moment it renders, and the measurement is cached by key, so a row that grows when its image loads, or wraps differently at a new width, moves everything under it and nothing above it. Until a row has been seen it is assumed to be estimateSize tall; the closer the guess, the less the scrollbar thumb resizes as you go. When a row above the viewport turns out taller or shorter than guessed, the scroll position is corrected by the difference, so what you are reading does not jump.

It scrolls itself. Put it in a parent with a height and it fills it, or give it height. With neither, it stops at the height of the screen rather than growing to the height of the data — a list ten thousand rows tall with nothing to scroll it would render every one of them.

Infinite scroll is onend: it fires once when the end comes within endThreshold px, and again only after items has grown. Pass loading while there is more to fetch, and it is drawn after the last row.

import { VirtualList } from 'omaris'
<VirtualList items={rows} estimateSize={56} getKey={(row) => row.id} height={480}>  {#snippet children(row, index)}    <ListItem headline={row.name} supporting={row.email} />  {/snippet}</VirtualList>

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
canvas
Sized to the whole list, so the scrollbar is honest while rows are virtual — and the element that carries role and the accessible name, so the list holds its rows and nothing else.
window
The rendered slice, translated to where it starts.
item
The wrapper round each row — what gets measured.
loading
Holds the loading snippet, after the last row.
empty
Holds the empty snippet when there are no items.

Props

items required
T[]

Everything in the list. Only the rows in view are rendered.

children required
Snippet<[T, number]>

One row. Handed the item and its index in items.

estimateSize

Defaults to 48

number | ((index: number) => number)

The size of a row not yet measured, in px — height, or width when horizontal. A function gets the index, for lists whose rows come in known kinds. Rows are measured as they render; this only has to be close.

overscan

Defaults to 6

number

Rows rendered beyond each edge of the viewport, so a fast flick never shows a gap.

getKey
(item: T, index: number) => string | number

A stable key per item. Measurements are cached under it, so a list that is prepended to or re-sorted keeps its measured sizes. Defaults to the index.

gap

Defaults to 0

number

Space between rows, in px.

horizontal

Defaults to false

boolean

Lay the rows out side by side and scroll sideways.

height
number | string

Height of the scroller. A number is px, a string any CSS length. Defaults to 100% — fill a parent that has a height — and to auto when horizontal. A property rather than a class, so classes.root can still replace it.

onend
() => void

Called when the end comes within endThreshold px — the hook for infinite scroll. Fires once per length of items, so appending a page re-arms it.

endThreshold

Defaults to 400

number

How far before the end onend fires, in px.

loading
Snippet

Drawn after the last row — pass it while there is more to load. With no items yet it is drawn instead of empty: the first page on its way is not an empty list.

empty
Snippet

Drawn in place of the list when items is empty.

itemRole
string

Role of each row's wrapper. Defaults to listitem under the default role="list", and to none under any other role — a listbox brings its own options.

onscroll
(event: Event) => void

Called on every scroll, after the window has been updated.

class
string
classes
VirtualListClasses

Per-part Tailwind overrides. class still covers the root.