VirtualList
A very long list that stays fast, because only the rows on screen exist.
Preview
tsx
import { Row, Text, VirtualList } from '@kerf-js/veneer'export function Log() { return ( <VirtualList label="Request log" items={LINES} rowKey={(l) => l.id} estimate={38} height="360px" > {logLine} </VirtualList> )}const WORDS = [ 'GET /checkout 200', 'POST /deploys 201', 'GET /search?q=shoes 200', 'GET /health 200', 'PUT /cache 204', 'GET /cart 304', 'POST /auth 401', 'GET /reports 500',]const pad = (n: number) => String(n).padStart(2, '0')const clock = (i: number) => { const hours = pad(16 + (Math.floor(i / 3600) % 8)) const minutes = pad(Math.floor(i / 60) % 60) const seconds = pad(i % 60) return `${hours}:${minutes}:${seconds}`}const LINES = Array.from( { length: 10000 }, (_, i) => ({ id: i, at: clock(i), text: WORDS[(i * 7) % 8], ms: 12 + ((i * 13) % 240), }),)function logLine( line: () => (typeof LINES)[number],) { return ( <Row gap="related" justify="between" wrap={false} > <Row gap="related" wrap={false} > <Text inline role="code" subtle > {line().at} </Text> <Text inline role="code" > {line().text} </Text> </Row> <Text inline role="caption" subtle > {line().ms} ms </Text> </Row> )}Installation
npm install @kerf-js/veneerTry it
Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.
Live preview
Controls
Height
Gap
Estimate
Codeupdates as you change the controls
import { VirtualList } from '@kerf-js/veneer'<VirtualList label="Request log" items={lines} rowKey={l => l.id} height="320px" estimate={38}> {line => <LogLine line={line()} />}</VirtualList>Usage
tsx
import { VirtualList } from '@kerf-js/veneer'<VirtualList label="Request log" items={LINES} rowKey={(l) => l.id} estimate={38} height="360px"> {logLine}</VirtualList>Rows of different heights
Each row is measured when it renders, and what's on screen never moves. Mark the text data-measure and pass text.
tsx
import { Stack, Text, VirtualList } from '@kerf-js/veneer'export function TallRows() { return ( <VirtualList label="Release notes" items={NOTES} rowKey={(n) => n.id} text={(n) => n.text} estimate={72} height="360px" > {(note) => ( <Stack gap="hair"> <Text role="label">{note().who}</Text> <Text data-measure="">{note().text}</Text> </Stack> )} </VirtualList> )}const DEPLOYS = [ { id: 'd-4821', project: 'storefront', branch: 'main', commit: '9f3ac21', message: 'Cache product images at the edge', author: 'Priya Raman', status: 'Live', minutes: 2, at: '2026-10-31T16:42', }, { id: 'd-4820', project: 'search-api', branch: 'ranking-v2', commit: '1be07d4', message: 'Rank exact matches first', author: 'Lena Fischer', status: 'Building', minutes: 6, at: '2026-10-31T16:10', }, { id: 'd-4819', project: 'mobile-bff', branch: 'main', commit: 'c44e190', message: 'Retry timed out upstream calls', author: 'Sam Okafor', status: 'Failed', minutes: 4, at: '2026-10-31T15:27', }, { id: 'd-4818', project: 'checkout', branch: 'main', commit: '70d2fa8', message: 'Add Apple Pay to the last step', author: 'Tomás Oliveira', status: 'Live', minutes: 3, at: '2026-10-31T14:03', }, { id: 'd-4817', project: 'edge-cache', branch: 'main', commit: 'e81b6c2', message: 'Lower the stale window to 30s', author: 'Jun Tanaka', status: 'Live', minutes: 1, at: '2026-10-31T11:55', }, { id: 'd-4816', project: 'billing', branch: 'main', commit: '2ad9e03', message: 'Prorate seats on plan change', author: 'Mara Lindqvist', status: 'Live', minutes: 3, at: '2026-10-29T17:20', },]const NOTES = Array.from( { length: 2000 }, (_, i) => { const d = DEPLOYS[i % 6]! const detail = 'Rolled out to every region after the checks ' + 'passed, with the old version kept warm ' + 'for an hour. ' const repeated = detail.repeat(i % 4) const message = `${d.message}. ${repeated}` return { id: i, who: d.author, text: message.trim(), } },)Jumping to a row
control hands you scrollTo, scrollToEnd and focus.
tsx
import { Button, Row, Stack, Text, VirtualList} from '@kerf-js/veneer'import type { VirtualControl } from '@kerf-js/veneer'export function Jump() { let list: VirtualControl | undefined return ( <Stack gap="related"> <Row gap="tight"> <Button size="sm" emphasis="outline" tone="neutral" onClick={() => list?.scrollTo(0, 'start')} > First </Button> <Button size="sm" emphasis="outline" tone="neutral" onClick={() => list?.scrollTo(5000, 'center')} > Row 5,000 </Button> <Button size="sm" emphasis="outline" tone="neutral" onClick={() => list?.scrollToEnd()} > Last </Button> </Row> <VirtualList label="Request log" items={LINES} rowKey={(l) => l.id} estimate={38} height="300px" control={(c) => (list = c)} > {logLine} </VirtualList> </Stack> )}const WORDS = [ 'GET /checkout 200', 'POST /deploys 201', 'GET /search?q=shoes 200', 'GET /health 200', 'PUT /cache 204', 'GET /cart 304', 'POST /auth 401', 'GET /reports 500',]const pad = (n: number) => String(n).padStart(2, '0')const clock = (i: number) => { const hours = pad(16 + (Math.floor(i / 3600) % 8)) const minutes = pad(Math.floor(i / 60) % 60) const seconds = pad(i % 60) return `${hours}:${minutes}:${seconds}`}const LINES = Array.from( { length: 10000 }, (_, i) => ({ id: i, at: clock(i), text: WORDS[(i * 7) % 8], ms: 12 + ((i * 13) % 240), }),)function logLine( line: () => (typeof LINES)[number],) { return ( <Row gap="related" justify="between" wrap={false} > <Row gap="related" wrap={false} > <Text inline role="code" subtle > {line().at} </Text> <Text inline role="code" > {line().text} </Text> </Row> <Text inline role="caption" subtle > {line().ms} ms </Text> </Row> )}Keyboard rows
roving gives one tab stop, then arrows, Home and End. Enter picks a row here.
tsx
import { state } from '@kerf-js/core'import { Row, Stack, Text, VirtualList} from '@kerf-js/veneer'import type { VirtualControl } from '@kerf-js/veneer'export function Keys() { const picked = state(0) const select = ( e: KeyboardEvent, c: VirtualControl, ) => { if (e.key === 'Enter') picked.value = PROJECTS.findIndex( (p) => p.id === c.active(), ) } return ( <Stack gap="related"> <Text subtle> Press Tab into the list, then the arrow keys, Home and End. Picked: {PROJECTS[picked.value]!.name}. </Text> <VirtualList label="Projects" items={PROJECTS} rowKey={(p) => p.id} estimate={38} height="240px" roving onRowKey={select} > {(p) => ( <Row justify="between" gap="tight" > <Text inline>{p().name}</Text> <Text inline role="caption" subtle > {p().region} </Text> </Row> )} </VirtualList> </Stack> )}type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const latency = (r: number, h: number) => 40 + (((r + 3) * 37 + h * h * 11) % 260)A live log
stick follows new rows while you're near the end. Scroll up and it stops. from="end" starts at the bottom.
tsx
import { onCleanup, onMount, state } from '@kerf-js/core'import { Button, Row, Stack, Text, Toggle, VirtualList} from '@kerf-js/veneer'import type { VirtualControl } from '@kerf-js/veneer'export function Tail() { const lines = state(LINES.slice(0, 40)) const paused = state(false) const stuck = state(true) let list: VirtualControl | undefined const arrive = () => { if (!paused.value) lines.value = [ ...lines.value, LINES[lines.value.length]!, ] } onMount(() => { const timer = setInterval(arrive, 700) onCleanup(() => clearInterval(timer)) }) return ( <Stack gap="related"> <Row gap="tight"> <Toggle size="sm" icon="pause" pressed={paused.value} onClick={() => (paused.value = !paused.value)} > Pause </Toggle> <Button size="sm" emphasis="ghost" tone="neutral" aria-disabled={stuck.value ? 'true' : undefined} onClick={() => list?.scrollToEnd()} > Jump to latest </Button> </Row> <VirtualList label="Live log" role="log" open from="end" stick={48} items={lines.value} rowKey={(l) => l.id} estimate={38} height="280px" onStick={(s) => (stuck.value = s)} control={(c) => (list = c)} > {logLine} </VirtualList> </Stack> )}const WORDS = [ 'GET /checkout 200', 'POST /deploys 201', 'GET /search?q=shoes 200', 'GET /health 200', 'PUT /cache 204', 'GET /cart 304', 'POST /auth 401', 'GET /reports 500',]const pad = (n: number) => String(n).padStart(2, '0')const clock = (i: number) => { const hours = pad(16 + (Math.floor(i / 3600) % 8)) const minutes = pad(Math.floor(i / 60) % 60) const seconds = pad(i % 60) return `${hours}:${minutes}:${seconds}`}const LINES = Array.from( { length: 10000 }, (_, i) => ({ id: i, at: clock(i), text: WORDS[(i * 7) % 8], ms: 12 + ((i * 13) % 240), }),)function logLine( line: () => (typeof LINES)[number],) { return ( <Row gap="related" justify="between" wrap={false} > <Row gap="related" wrap={false} > <Text inline role="code" subtle > {line().at} </Text> <Text inline role="code" > {line().text} </Text> </Row> <Text inline role="caption" subtle > {line().ms} ms </Text> </Row> )}Loading more
onEnd fires near the end, once per length.
tsx
import { state } from '@kerf-js/core'import { Row, Stack, Text, VirtualList} from '@kerf-js/veneer'export function LoadMore() { const shown = state(LINES.slice(0, 30)) const busy = state(false) const more = async () => { busy.value = true await new Promise((done) => { setTimeout(done, 600) }) shown.value = LINES.slice(0, shown.value.length + 30) busy.value = false } return ( <Stack gap="related"> <Text subtle> {shown.value.length} of {LINES.length} loaded {busy.value ? ', loading more' : ''}. </Text> <VirtualList label="Request log" items={shown.value} rowKey={(l) => l.id} estimate={38} height="280px" busy={busy.value} onEnd={() => void more()} > {logLine} </VirtualList> </Stack> )}const WORDS = [ 'GET /checkout 200', 'POST /deploys 201', 'GET /search?q=shoes 200', 'GET /health 200', 'PUT /cache 204', 'GET /cart 304', 'POST /auth 401', 'GET /reports 500',]const pad = (n: number) => String(n).padStart(2, '0')const clock = (i: number) => { const hours = pad(16 + (Math.floor(i / 3600) % 8)) const minutes = pad(Math.floor(i / 60) % 60) const seconds = pad(i % 60) return `${hours}:${minutes}:${seconds}`}const LINES = Array.from( { length: 10000 }, (_, i) => ({ id: i, at: clock(i), text: WORDS[(i * 7) % 8], ms: 12 + ((i * 13) % 240), }),)function logLine( line: () => (typeof LINES)[number],) { return ( <Row gap="related" justify="between" wrap={false} > <Row gap="related" wrap={false} > <Text inline role="code" subtle > {line().at} </Text> <Text inline role="code" > {line().text} </Text> </Row> <Text inline role="caption" subtle > {line().ms} ms </Text> </Row> )}When to use
Use it for
- Logs, messages and search results in the thousands
- Any list that scrolls inside a fixed height
Reach for something else
- Table, when the rows have sortable columns
- Feed, when it is a stream of activity
- PaginationBar, when people look for a place rather than scroll
Accessibility
- ↑↓
- With `roving`, moves between rows
- HomeorEnd
- With `roving`, goes to the first or last row
- Rows carry
aria-posinsetandaria-setsize, so a screen reader hears "12 of 10,000". - Pass
label. It names the list. - A live list that may grow should use
role="log"andopen.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly T[] | — | The rows it shows. |
rowKey | (item: T) => VirtualKey | — | What identifies a row. |
children | (item: () => T, index: () => number) => unknown | — | Draws a row. It gets the item and its index as functions, so a row can be reused. |
label | string | — | The list's name. |
text | ((item: T) => string | null | undefined) | — | The row's text. Rows get exact heights before they render when the app has text.measure, and it's measured otherwise. |
variant | ((item: T) => string) | — | Rows that look different around the same text: each gets its own measurement of everything but the text. |
estimate | number | — | A row's height before it is measured. Close is better. Default 48. |
gap | number | — | Pixels between rows. |
overscan | number | — | Pixels of rows kept rendered past each edge of the view, so a fast scroll shows no gaps. |
initial | number | — | Rows on the server and in the first paint. Default 20. |
stick | number | — | Stay at the end as rows arrive, while within this many pixels of it. |
from | "start" | "end" | — | end opens at the last row and grows upward, as a chat does. |
role | string | — | list by default (rows are listitems; group with bareRows); feed, log and the rest bring their own row role. |
rowRole | string | — | The role each row takes, such as option or article. |
bareRows | boolean | false | Rows take no list semantics: what's inside them says what it is, and the scroller is a labelled group. |
inner | boolean | false | Keep this role off the scroller and put it on the list inside. |
tag | "div" | "ol" | — | ol renders an ordered list with <li> rows; div otherwise. |
roving | boolean | false | Row keys move focus: ↑/↓, Home/End, one tab stop on the rows. |
open | boolean | false | The total isn't the list's to know (a log with history to load): aria-setsize is -1, and an arrival writes no row. |
control | ((c: VirtualControl) => void) | — | Hands over a control to scroll to, focus or move between rows from outside. |
tabindex | number | — | The scroller's tabindex. Default 0, none with roving. |
scope | string | — | The scroller's data-scope, for styling it as another component. |
part | string | — | The scroller's data-part, when it is a part of a larger component. |
listScope | string | — | The inner list's data-scope. |
height | string | — | The scroller's height. Default 280px. |
busy | boolean | false | aria-busy: more is loading. |
| Event | Type | Default | Description |
|---|---|---|---|
onRowKey | ((e: KeyboardEvent, control: VirtualControl) => void) | — | On a key the list doesn't use. |
onStart | (() => void) | — | Called when the view nears the first row: load older rows. |
onEnd | (() => void) | — | Called when the view nears the last row: load more. |
onStick | ((stuck: boolean) => void) | — | Called with true when the list starts holding to the end, and false when the user scrolls away. |
onDrift | ((index: number, predicted: number, actual: number) => void) | — | Called when a row's measured height differs from the estimate, with both heights. For tuning. |
RelatedFeedScroll gridTable