ScrollGrid
A huge read-only grid that scrolls both ways: thousands of rows and columns, and only what you see exists.
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/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
Rows
Header width
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
- DataGrid, when people edit, sort or group
- VirtualList, when there is only one column
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-rowindexandaria-colindexkeep each cell's place in the whole. - One tab stop. The active cell is announced as you move.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Its accessible name. Fields show it; icon-only controls speak it. |
rows | number | — | How many rows there are. Cells are asked for as they come into view. |
columns | readonly 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. |
corner | string | — | The top-left cell. Default label. |
height | string | — | Default 480px. |
rowHeight | number | — | Each row's height, in pixels. Default 34. |
headerWidth | number | — | 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