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