Display
Virtual list
For lists of thousands of rows: only the rows in view are drawn, so it scrolls as smoothly as a short one. Rows can be any height.
Installation
npx sinaui add virtual-listCopy src/lib/ui/VirtualList.svelte, scroll-edges.ts and tokens.css. Then install TanStack Virtual:
pnpm add @tanstack/svelte-virtualExamples
Ten thousand rows
Scroll as fast as you like: only about twenty rows exist in the page at any moment.
10,000 manuscripts
<script lang="ts">
import VirtualList from '#lib/ui/VirtualList.svelte';
// Ten thousand entries: only the few in view are ever in the page.
const subjects = ['Optics', 'Algebra', 'Medicine', 'Astronomy', 'Geography', 'Poetry', 'Law'];
const cities = ['Baghdad', 'Cairo', 'Córdoba', 'Damascus', 'Isfahan', 'Samarkand', 'Fez'];
const entries = Array.from({ length: 10_000 }, (_, i) => ({
id: i + 1,
subject: subjects[i % subjects.length],
city: cities[(i * 3) % cities.length],
year: 800 + ((i * 37) % 600)
}));
const n = new Intl.NumberFormat('en');
</script>
<div class="stack">
<VirtualList items={entries} key={(e) => e.id} label="Catalog" height="22rem" class="catalog">
{#snippet children(e)}
<div class="row">
<span class="id">MS-{String(e.id).padStart(5, '0')}</span>
<span class="what">{e.subject}, {e.city}</span>
<span class="year">{e.year} CE</span>
</div>
{/snippet}
</VirtualList>
<p class="note">{n.format(entries.length)} manuscripts</p>
</div>
<style>
.stack {
display: grid;
justify-items: center;
inline-size: min(28rem, 100%);
}
:global(.catalog) {
inline-size: min(28rem, 100%);
border-radius: 1rem;
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
}
.row {
display: flex;
gap: 0.75rem;
align-items: baseline;
padding: 0.625rem 1rem;
box-shadow: inset 0 -1px var(--ui-line);
font-size: 0.875rem;
}
.id {
color: var(--ui-muted);
font: 0.8125rem/1 var(--ui-font-mono);
}
.what {
flex: 1;
}
.year {
color: var(--ui-muted);
font-variant-numeric: tabular-nums;
}
.note {
margin: 0.75rem 0 0;
color: var(--ui-muted);
font-size: 0.875rem;
text-align: center;
}
</style>Loading more as you scroll
onend runs as the last rows come into view; add the next page to items and the list grows.
Loading more…
<script lang="ts">
import Spinner from '#lib/ui/Spinner.svelte';
import VirtualList from '#lib/ui/VirtualList.svelte';
// Pages of 50 arrive as you near the end, up to 500: the shape of an API with cursors.
const TOTAL = 500;
let letters = $state<{ id: number; from: string }[]>([]);
let loading = $state(false);
const senders = ['al-Kindi', 'Ibn Sina', 'al-Biruni', 'Ibn Rushd', 'al-Farabi', 'Ibn Khaldun'];
async function more() {
if (loading || letters.length >= TOTAL) return;
loading = true;
await new Promise((r) => setTimeout(r, 700));
const start = letters.length;
letters.push(
...Array.from({ length: 50 }, (_, i) => ({
id: start + i + 1,
from: senders[(start + i) % senders.length]
}))
);
loading = false;
}
more();
</script>
<VirtualList
items={letters}
key={(l) => l.id}
label="Letters"
height="20rem"
onend={more}
class="letters"
>
{#snippet children(l)}
<div class="row"><strong>Letter {l.id}</strong> <span>from {l.from}</span></div>
{/snippet}
{#snippet footer()}
<p class="end" aria-live="polite">
{#if loading}<Spinner size={16} /> Loading more…{:else if letters.length >= TOTAL}That's every
letter.{/if}
</p>
{/snippet}
</VirtualList>
<style>
:global(.letters) {
inline-size: min(24rem, 100%);
border-radius: 1rem;
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
}
.row {
display: flex;
gap: 0.5rem;
padding: 0.625rem 1rem;
box-shadow: inset 0 -1px var(--ui-line);
font-size: 0.875rem;
}
.row span {
color: var(--ui-muted);
}
.end {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
min-block-size: 2.5rem;
margin: 0;
color: var(--ui-muted);
font-size: 0.8125rem;
}
</style>Props
| Prop | Type | Default |
|---|---|---|
items | T[] | required |
key | (item) => string | number | required |
label | string | required |
estimate | number, a guess at a row's height in px | 48 |
gap | number, px between rows | 0 |
height | CSS length | '24rem' |
onend | () => void | |
children | snippet: (item, index) | required |
footer | snippet after the rows |
Accessibility
- A list that can take focus and scroll with the keyboard.
- Each row is read with its place in the whole list (“row 5,021 of 10,000”), not just among those drawn.
- Browser find (⌘F) only sees the rows drawn; offer your own search for long lists.