Data Display
Virtual List
A windowed list: ten thousand rows, and only the thirty on screen exist.
import { VirtualList } from 'omaris' 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.
<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.
<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.
<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.
<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:
onendto fetch the next page,loadingdrawn after the last row while there is more. - Jumping to a row from outside — a search hit, "back to where I was" — with
bind:thisandscrollToIndex(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
getKeywhenever items can be inserted, removed or re-sorted, so the measured sizes follow the rows rather than the positions. - Set
estimateSizeto 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 alistwith a name, and every row carriesaria-setsizeandaria-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
onendwithout guarding against one already in flight — it re-arms whenitemsgrows, 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
roleand 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
loadingsnippet, after the last row. empty- Holds the
emptysnippet 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.