Skip to content
kerfjsGitHub

VirtualList

A very long list that stays fast, because only the rows on screen exist.

SourcePackage @kerf-js/veneerTry it

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

Try 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
Gap0
Estimate38
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-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.
estimatenumber—A 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.
initialnumber—Rows 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.
tabindexnumber—The 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.

RelatedFeedScroll gridTable