Skip to content
Data Display

Virtual List

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

Walkthrough

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

2. 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}

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

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