Skip to content
kerfjsGitHub

ScrollGrid

A huge read-only grid that scrolls both ways: thousands of rows and columns, and only what you see exists.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
import { ScrollGrid } from '@kerf-js/veneer'export function Latency() {  return (  <ScrollGrid    label="Latency by hour, in milliseconds"    corner="Route"    rows={2000}    columns={HOURS}    rowHeader={(r) => `${PROJECTS[r % 10]!.name}/${r}`}    cell={(r, c) => latency(r, Number(c.id))}    height="400px"  />  )}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 HOURS = Array.from(  { length: 24 },  (_, h) => ({    id: String(h),    header: `${String(h).padStart(2, '0')}:00`,  }),)const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

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
Rows2000
Header width132
Codeupdates as you change the controls
import { ScrollGrid } from '@kerf-js/veneer'<ScrollGrid label="Latency by hour" rows={2000} columns={hours} rowHeader={route} cell={latency} rows={2000} />

Usage

tsx
import { ScrollGrid } from '@kerf-js/veneer'<ScrollGrid  label="Latency by hour, in milliseconds"  corner="Route"  rows={2000}  columns={HOURS}  rowHeader={(r) => `${PROJECTS[r % 10]!.name}/${r}`}  cell={(r, c) => latency(r, Number(c.id))}  height="400px"/>

Flagging values

negative marks a cell in the critical colour.

tsx
import { ScrollGrid } from '@kerf-js/veneer'export function Negative() {  return (  <ScrollGrid    label="Change in spend by month"    corner="Project"    rows={500}    columns={MONTH_COLUMNS}    rowHeader={(r) => `${PROJECTS[r % 10]!.name}-${r}`}    cell={(r, c) => showSwing(swing(r, Number(c.id)))}    negative={(r, c) => swing(r, Number(c.id)) < 0}    height="320px"  />  )}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',  },]type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const spend = (rows: Project[]) =>  `Total ${money(rows.reduce((n, p) => n + p.cost, 0))}`const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)const MONTH_NAMES = [  'Jan',  'Feb',  'Mar',  'Apr',  'May',  'Jun',  'Jul',  'Aug',  'Sep',  'Oct',  'Nov',  'Dec',]const MONTH_COLUMNS = MONTH_NAMES.map((m, i) => ({  id: String(i),  header: m,}))const swing = (r: number, m: number) =>  (((r + 2) * 53 + (m + 1) * 29) % 41) - 14const showSwing = (n: number) =>  `${n > 0 ? '+' : n < 0 ? '−' : ''}${Math.abs(n)}%`

Column widths

Each column sets its own width, and headerWidth sizes the first column.

tsx
import { ScrollGrid } from '@kerf-js/veneer'export function Widths() {  return (  <ScrollGrid    label="Projects"    corner="Project"    rows={1000}    columns={SIZED}    rowHeader={(r) => `${PROJECTS[r % 10]!.name}-${r}`}    cell={(r, c) => fieldOf(r, c.id)}    headerWidth={160}    height="300px"  />  )}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 money = (n: number) =>  `$${n.toLocaleString('en-US')}`const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)const SIZED = [  { id: 'owner', header: 'Owner', width: 170 },  { id: 'region', header: 'Region', width: 110 },  { id: 'status', header: 'Status', width: 96 },  { id: 'cost', header: 'Cost', width: 96 },]const fieldOf = (r: number, id: string) => {  const p = PROJECTS[r % 10]!  return id === 'cost'    ? money(p.cost + r)    : p[id as 'owner']}

When to use

Use it for

  • Metrics with many rows and many columns
  • Anything far past what a Table should hold

Reach for something else

Accessibility

↑↓←→
Moves one cell
PageUporPageDown
Moves one screen
HomeorEnd
Goes to the row's ends
⌘Homeor⌘End
Goes to the grid's ends
  • Only the rows in view exist, so aria-rowindex and aria-colindex keep each cell's place in the whole.
  • One tab stop. The active cell is announced as you move.

API Reference

PropTypeDefaultDescription
labelstring—Its accessible name. Fields show it; icon-only controls speak it.
rowsnumber—How many rows there are. Cells are asked for as they come into view.
columnsreadonly GridColumn[]—Each with id, header and an optional width.
cell(row: number, column: GridColumn) => unknown—What a row and column hold.
rowHeader(row: number) => string—The first column's text.
cornerstring—The top-left cell. Default label.
heightstring—Default 480px.
rowHeightnumber—Each row's height, in pixels. Default 34.
headerWidthnumber—The pinned first column's width, in pixels. Default 132.
negative((row: number, column: GridColumn) => boolean)—Marks a cell critical.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="scroll-grid"] [data-part="canvas"]

canvashead-rowcorner

RelatedData gridVirtual listTable