Skip to content
weftGitHub

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/warp

Usage

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) % 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 '@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
Gap0
Estimate38
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-posinset and aria-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" and open.

API Reference

PropTypeDefaultDescription
itemsreadonly 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.
labelstring—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.
estimatenumber48A row's height before it is measured. Close is better. Default 48.
gapnumber—Pixels between rows.
overscannumber—Pixels of rows kept rendered past each edge of the view, so a fast scroll shows no gaps.
initialnumber20Rows on the server and in the first paint. Default 20.
sticknumber—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.
rolestring—list by default (rows are listitems; group with bareRows); feed, log and the rest bring their own row role.
rowRolestring—The role each row takes, such as option or article.
bareRowsbooleanfalseRows take no list semantics: what's inside them says what it is, and the scroller is a labelled group.
innerbooleanfalseKeep 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.
rovingbooleanfalseRow keys move focus: ↑/↓, Home/End, one tab stop on the rows.
openbooleanfalseThe 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.
tabindexnumber0The scroller's tabindex. Default 0, none with roving.
scopestring—The scroller's data-scope, for styling it as another component.
partstring—The scroller's data-part, when it is a part of a larger component.
listScopestring—The inner list's data-scope.
heightstring—The scroller's height. Default 280px.
busybooleanfalsearia-busy: more is loading.
EventTypeDefaultDescription
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