VirtualList
A very long list that stays fast, because only the rows on screen exist.
SourcePackage @weft-js/warpPlayground
Preview
tsx
import { Row, Text, VirtualList } from '@weft-js/warp'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) => `${pad(16 + Math.floor(i / 3600) % 8)}:${pad(Math.floor(i / 60) % 60)}:${pad(i % 60)}`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 @weft-js/warpUsage
tsx
import { VirtualList } from '@weft-js/warp'<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 '@weft-js/warp'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]! return { id: i, who: d.author, text: `${d.message}. ${'Rolled out to every region after the checks passed, with the old version kept warm for an hour. '.repeat(i % 4)}`.trim() }})Jumping to a row
control hands you scrollTo, scrollToEnd and focus.
tsx
import { Button, Row, Stack, Text, VirtualList } from '@weft-js/warp'import type { VirtualControl } from '@weft-js/warp'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) => `${pad(16 + Math.floor(i / 3600) % 8)}:${pad(Math.floor(i / 60) % 60)}:${pad(i % 60)}`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 '@weft-js/core'import { Row, Stack, Text, VirtualList } from '@weft-js/warp'export function Keys() { const picked = state(0) 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={(e, c) => { if (e.key === 'Enter') picked.value = PROJECTS.findIndex(p => p.id === c.active()) }}> {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) % 260A 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 '@weft-js/core'import { Button, Row, Stack, Text, Toggle, VirtualList } from '@weft-js/warp'import type { VirtualControl } from '@weft-js/warp'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) => `${pad(16 + Math.floor(i / 3600) % 8)}:${pad(Math.floor(i / 60) % 60)}:${pad(i % 60)}`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 '@weft-js/core'import { Row, Stack, Text, VirtualList } from '@weft-js/warp'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) => `${pad(16 + Math.floor(i / 3600) % 8)}:${pad(Math.floor(i / 60) % 60)}:${pad(i % 60)}`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
Playground
Height
Gap
Estimate
import { VirtualList } from '@weft-js/warp'<VirtualList label="Request log" items={lines} rowKey={l => l.id} height="320px" estimate={38}> {line => <LogLine line={line()} />}</VirtualList>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 | 48 | 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 | 20 | 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 | 0 | 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. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="virtual-list"] [data-part="row"]
row
RelatedFeedScroll gridTable