Skip to content
kerfjsGitHub

DataGrid

A table you work in like a spreadsheet: move by cell, select ranges, edit, group and export.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
import { Badge, DataGrid } from '@kerf-js/veneer'import type { GridColumn } from '@kerf-js/veneer'export function Basic() {  return (  <DataGrid    caption="Projects"    captionHidden    rows={PROJECTS}    columns={GRID}    exportName="kerf-projects"  />  )}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 TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const badge = (p: { status: string }) => (  <Badge    tone={TONE_OF[p.status as Status]}    dot  >    {p.status}  </Badge>)const STATUSES = [  'Live',  'Building',  'Failed',  'Paused',]const REGION_NAMES = [  'eu-west',  'eu-north',  'us-east',  'us-west',  'ap-south',  'global',]const GRID: GridColumn<Project>[] = [  {    id: 'name',    header: 'Project',    rowHeader: true,    pin: true,    sort: true,    filter: 'text',  },  {    id: 'status',    header: 'Status',    sort: true,    filter: 'select',    options: STATUSES,    cell: badge,  },  {    id: 'region',    header: 'Region',    sort: true,    filter: 'select',    options: REGION_NAMES,  },  {    id: 'owner',    header: 'Owner',    sort: true,    filter: 'text',  },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',  },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',    format: (v) => money(Number(v)),  },]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
Density
Group by
Export name
Codeupdates as you change the controls
import { DataGrid } from '@kerf-js/veneer'<DataGrid caption="Projects" rows={projects} columns={columns} groupBy="region" />

Usage

tsx
import { DataGrid } from '@kerf-js/veneer'<DataGrid  caption="Projects"  captionHidden  rows={PROJECTS}  columns={GRID}  exportName="kerf-projects"/>

Editing

Enter or a double-click edits. A bad value never commits. Paste a block from a spreadsheet and it lands cell by cell.

tsx
import { state } from '@kerf-js/core'import { DataGrid, Stack, Text } from '@kerf-js/veneer'import type { GridColumn } from '@kerf-js/veneer'export function Edit() {  const rows = state(PROJECTS)  const last = state('Nothing changed yet.')  const save = (    row: Project,    column: string,    value: unknown,  ) => {    rows.value = rows.value.map((p) =>      p.id === row.id ? { ...p, [column]: value } : p,    )    const result = String(value)    const message = `${row.name}: ${column} is now `    last.value = `${message}${result}.`  }  return (    <Stack gap="related">      <Text subtle>{last.value}</Text>      <DataGrid        caption="Budgets"        captionHidden        rows={rows}        columns={NUMBERS}        onEdit={save}        exportName={false}      />    </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',  },]type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const NUMBERS: GridColumn<Project>[] = [  {    id: 'name',    header: 'Project',    rowHeader: true,    sort: true,    edit: true,    validate: (v) =>      String(v).trim() !== '' || 'A project needs a name',  },  {    id: 'owner',    header: 'Owner',    sort: true,    edit: true,  },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    aggregate: 'sum',  },  {    id: 'cost',    header: 'Budget',    numeric: true,    sort: true,    aggregate: 'sum',    format: (v) => money(Number(v)),    edit: (text) => Number(text.replace(/[$,]/g, '')),    validate: (v) =>      (typeof v === 'number' &&        Number.isFinite(v) &&        v >= 0) ||      'Enter an amount',  },]const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

Groups and totals

Press Group to toggle. aggregate fills each group's subtotal and the footer.

tsx
import { Badge, DataGrid } from '@kerf-js/veneer'import type {  GridColumn,  GridView} from '@kerf-js/veneer'export function Groups() {  return (  <DataGrid    caption="Projects by region"    captionHidden    rows={PROJECTS}    columns={GROUPED_GRID}    groupBy="region"    views={GROUPED}    exportName={false}  />  )}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 TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const badge = (p: { status: string }) => (  <Badge    tone={TONE_OF[p.status as Status]}    dot  >    {p.status}  </Badge>)const STATUSES = [  'Live',  'Building',  'Failed',  'Paused',]const REGION_NAMES = [  'eu-west',  'eu-north',  'us-east',  'us-west',  'ap-south',  'global',]const spend = (rows: Project[]) =>  `Total ${money(rows.reduce((n, p) => n + p.cost, 0))}`const GROUPED_GRID: GridColumn<Project>[] = [  {    id: 'name',    header: 'Project',    rowHeader: true,    pin: true,    sort: true,    aggregate: spend,  },  {    id: 'status',    header: 'Status',    sort: true,    filter: 'select',    options: STATUSES,    cell: badge,  },  {    id: 'region',    header: 'Region',    sort: true,    filter: 'select',    options: REGION_NAMES,  },  { id: 'owner', header: 'Owner', sort: true },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    format: (v) => money(Number(v)),  },]const GROUPED: Record<string, GridView> = {  'By region': {    order: ['name', 'status', 'region', 'owner', 'cost'],    hidden: [],    filters: {},    group: true,  },}const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

Saved views

A view holds columns, filters, sort and grouping. Pick one, change it, then Save view.

tsx
import { Badge, DataGrid } from '@kerf-js/veneer'import type {  GridColumn,  GridView} from '@kerf-js/veneer'export function Views() {  return (  <DataGrid    caption="Projects"    captionHidden    rows={PROJECTS}    columns={GRID}    views={VIEWS}    exportName={false}  />  )}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 TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const badge = (p: { status: string }) => (  <Badge    tone={TONE_OF[p.status as Status]}    dot  >    {p.status}  </Badge>)const STATUSES = [  'Live',  'Building',  'Failed',  'Paused',]const REGION_NAMES = [  'eu-west',  'eu-north',  'us-east',  'us-west',  'ap-south',  'global',]const GRID: GridColumn<Project>[] = [  {    id: 'name',    header: 'Project',    rowHeader: true,    pin: true,    sort: true,    filter: 'text',  },  {    id: 'status',    header: 'Status',    sort: true,    filter: 'select',    options: STATUSES,    cell: badge,  },  {    id: 'region',    header: 'Region',    sort: true,    filter: 'select',    options: REGION_NAMES,  },  {    id: 'owner',    header: 'Owner',    sort: true,    filter: 'text',  },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',  },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',    format: (v) => money(Number(v)),  },]const ORDER = [  'name',  'status',  'region',  'owner',  'deploys',  'cost',]const VIEWS: Record<string, GridView> = {  'All projects': {    order: ORDER,    hidden: [],    filters: {},  },  'Needs attention': {    order: ORDER,    hidden: ['deploys'],    filters: { status: 'Failed' },  },  Costly: {    order: ['name', 'cost', 'owner', 'region'],    hidden: ['status', 'deploys'],    filters: { cost: '1000' },    sort: { id: 'cost', dir: 'descending' },  },}const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

A tree

Rows with kids become branches, and each parent rolls up its children. Filtering keeps the path to each match.

tsx
import { DataGrid } from '@kerf-js/veneer'import type {  GridColumn,  GridTree} from '@kerf-js/veneer'export function Tree() {  return (  <DataGrid    caption="Spend by team"    captionHidden    rows={SPEND}    columns={SPEND_COLUMNS}    tree={OWN_KIDS}    filter="Filter teams and projects"    exportName={false}  />  )}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')}`type Spend = {  id: string  name: string  deploys: number  cost: number  kids?: Spend[] | undefined  hasChildren?: boolean}const leaf = (p: Project): Spend => ({  id: p.id,  name: p.name,  deploys: p.deploys,  cost: p.cost,})const team = (  name: string,  ...ids: string[]): Spend => ({  id: name,  name,  deploys: 0,  cost: 0,  kids: PROJECTS.filter((p) => ids.includes(p.id)).map(    leaf,  ),})const SPEND: Spend[] = [  team(    'Platform',    'billing',    'notifier',    'edge-cache',    'auth',  ),  team('Web', 'storefront', 'checkout'),  team('Data', 'search-api', 'reports', 'ingest'),  team('Mobile', 'mobile-bff'),]const SPEND_COLUMNS: GridColumn<Spend>[] = [  { id: 'name', header: 'Team and project', sort: true },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    aggregate: 'sum',  },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    aggregate: 'sum',    format: (v) => money(Number(v)),  },]const OWN_KIDS: GridTree<Spend> = {}const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

A tree that loads

load fetches a branch on its first open.

tsx
import { DataGrid } from '@kerf-js/veneer'import type {  GridColumn,  GridTree} from '@kerf-js/veneer'export function LazyTree() {  return (  <DataGrid    caption="Spend by team"    captionHidden    rows={LAZY}    columns={SPEND_COLUMNS}    tree={LOAD}    exportName={false}  />  )}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')}`type Spend = {  id: string  name: string  deploys: number  cost: number  kids?: Spend[] | undefined  hasChildren?: boolean}const leaf = (p: Project): Spend => ({  id: p.id,  name: p.name,  deploys: p.deploys,  cost: p.cost,})const team = (  name: string,  ...ids: string[]): Spend => ({  id: name,  name,  deploys: 0,  cost: 0,  kids: PROJECTS.filter((p) => ids.includes(p.id)).map(    leaf,  ),})const SPEND: Spend[] = [  team(    'Platform',    'billing',    'notifier',    'edge-cache',    'auth',  ),  team('Web', 'storefront', 'checkout'),  team('Data', 'search-api', 'reports', 'ingest'),  team('Mobile', 'mobile-bff'),]const SPEND_COLUMNS: GridColumn<Spend>[] = [  { id: 'name', header: 'Team and project', sort: true },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    aggregate: 'sum',  },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    aggregate: 'sum',    format: (v) => money(Number(v)),  },]const LAZY: Spend[] = SPEND.map((t) => ({  ...t,  kids: undefined,  hasChildren: true,  deploys: t.kids!.reduce((n, k) => n + k.deploys, 0),  cost: t.kids!.reduce((n, k) => n + k.cost, 0),}))const LOAD: GridTree<Spend> = {  branch: (row) => !!row.hasChildren,  load: async (row) => {    await new Promise((done) => {      setTimeout(done, 700)    })    return SPEND.find((t) => t.id === row.id)?.kids ?? []  },}const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

Bulk actions

Shift-select across rows and the bar appears over them.

tsx
import { state } from '@kerf-js/core'import { Badge, Button, DataGrid } from '@kerf-js/veneer'import type { GridColumn } from '@kerf-js/veneer'export function Bulk() {  const rows = state(PROJECTS)  const pause = (keys: string[]) =>    (rows.value = rows.value.map((p) =>      keys.includes(p.id)        ? { ...p, status: 'Paused' as const }        : p,    ))  return (    <DataGrid      caption="Projects"      captionHidden      rows={rows}      columns={GRID}      exportName={false}      bulk={(keys) => (        <Button          size="sm"          emphasis="outline"          tone="neutral"          icon="pause"          onClick={() => pause(keys)}        >          Pause        </Button>      )}    />  )}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 TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const badge = (p: { status: string }) => (  <Badge    tone={TONE_OF[p.status as Status]}    dot  >    {p.status}  </Badge>)const STATUSES = [  'Live',  'Building',  'Failed',  'Paused',]const REGION_NAMES = [  'eu-west',  'eu-north',  'us-east',  'us-west',  'ap-south',  'global',]const GRID: GridColumn<Project>[] = [  {    id: 'name',    header: 'Project',    rowHeader: true,    pin: true,    sort: true,    filter: 'text',  },  {    id: 'status',    header: 'Status',    sort: true,    filter: 'select',    options: STATUSES,    cell: badge,  },  {    id: 'region',    header: 'Region',    sort: true,    filter: 'select',    options: REGION_NAMES,  },  {    id: 'owner',    header: 'Owner',    sort: true,    filter: 'text',  },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',  },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',    format: (v) => money(Number(v)),  },]const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

Row details

Use expand to open extra detail under a row.

tsx
import {  Badge,  DataGrid,  Row,  Stack,  Text} from '@kerf-js/veneer'import type { GridColumn } from '@kerf-js/veneer'export function Details() {  return (  <DataGrid    caption="Projects"    captionHidden    rows={PROJECTS}    columns={DETAIL_GRID}    expand={gridDetails}    exportName={false}  />  )}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 TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const badge = (p: { status: string }) => (  <Badge    tone={TONE_OF[p.status as Status]}    dot  >    {p.status}  </Badge>)const details = (p: Project) => (  <Row gap="section">    <Stack gap="hair">      <Text        role="label"        subtle      >        Uptime      </Text>      <Text>{p.uptime}%</Text>    </Stack>    <Stack gap="hair">      <Text        role="label"        subtle      >        Latency      </Text>      <Text>{p.latency} ms</Text>    </Stack>    <Stack gap="hair">      <Text        role="label"        subtle      >        Updated      </Text>      <Text>{p.updated}</Text>    </Stack>  </Row>)const STATUSES = [  'Live',  'Building',  'Failed',  'Paused',]const REGION_NAMES = [  'eu-west',  'eu-north',  'us-east',  'us-west',  'ap-south',  'global',]const GRID: GridColumn<Project>[] = [  {    id: 'name',    header: 'Project',    rowHeader: true,    pin: true,    sort: true,    filter: 'text',  },  {    id: 'status',    header: 'Status',    sort: true,    filter: 'select',    options: STATUSES,    cell: badge,  },  {    id: 'region',    header: 'Region',    sort: true,    filter: 'select',    options: REGION_NAMES,  },  {    id: 'owner',    header: 'Owner',    sort: true,    filter: 'text',  },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',  },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',    format: (v) => money(Number(v)),  },]const DETAIL_GRID = GRID.filter(  (c) => c.id !== 'deploys',)const gridDetails = (p: Project) => (  <Row gap="section">    <Stack gap="hair">      <Text        role="label"        subtle      >        Deploys      </Text>      <Text>{p.deploys}</Text>    </Stack>    {details(p)}  </Row>)const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

Pages

Use page to show a few rows at a time, here four.

tsx
import { Badge, DataGrid } from '@kerf-js/veneer'import type { GridColumn } from '@kerf-js/veneer'export function Paged() {  return (  <DataGrid    caption="Projects"    captionHidden    rows={PROJECTS}    columns={GRID}    page={FOUR}    exportName={false}  />  )}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 TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}type Project = (typeof PROJECTS)[number]const money = (n: number) =>  `$${n.toLocaleString('en-US')}`const badge = (p: { status: string }) => (  <Badge    tone={TONE_OF[p.status as Status]}    dot  >    {p.status}  </Badge>)const STATUSES = [  'Live',  'Building',  'Failed',  'Paused',]const REGION_NAMES = [  'eu-west',  'eu-north',  'us-east',  'us-west',  'ap-south',  'global',]const GRID: GridColumn<Project>[] = [  {    id: 'name',    header: 'Project',    rowHeader: true,    pin: true,    sort: true,    filter: 'text',  },  {    id: 'status',    header: 'Status',    sort: true,    filter: 'select',    options: STATUSES,    cell: badge,  },  {    id: 'region',    header: 'Region',    sort: true,    filter: 'select',    options: REGION_NAMES,  },  {    id: 'owner',    header: 'Owner',    sort: true,    filter: 'text',  },  {    id: 'deploys',    header: 'Deploys',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',  },  {    id: 'cost',    header: 'Cost',    numeric: true,    sort: true,    filter: 'min',    aggregate: 'sum',    format: (v) => money(Number(v)),  },]const FOUR = { size: 4 }const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)

When to use

Use it for

  • People edit or copy many cells
  • Analysts who filter, group and save views

Reach for something else

  • Table, when people mostly read rows
  • ScrollGrid, when it is read-only and very large
  • PivotTable, when you want totals across two dimensions

Accessibility

↑↓←→
Moves the cell
Shift+arrows
Extends the range
⌘C
Copies the range as tab-separated text
⌘V
Pastes into editable cells
EnterorF2
Edits the cell
Esc
Cancels the edit
Alt←→
Moves the column
→←
In a tree, opens and closes a branch
  • One tab stop, then arrows: the APG grid pattern, or treegrid when grouped or hierarchical.
  • Moves, edits and exports are announced.
  • An invalid edit stays open and says why.

API Reference

PropTypeDefaultDescription
rowsreadonly T[] | State<readonly T[]>—The rows, as an array or a State of one.
columnsGridColumn<T>[]—As in Table, plus filter, options, aggregate and format.
captionstring—Names the grid (required). It stays in the accessibility tree when captionHidden.
captionHiddenbooleanfalseHides the caption on screen. Screen readers still hear it as the grid's name.
rowKeystring | ((row: T) => string)—The field that identifies a row, or a function that returns it. Keys a row's selection, edits and saved state. Default id.
density"compact" | "cozy" | "comfortable"—Row height and cell padding: compact fits the most rows, comfortable reads easiest. Default cozy.
viewsRecord<string, GridView>—Named views for the View select.
viewstring—Which view it starts on.
groupBystring—The column the Group button groups by.
treeGridTree<T>—Hierarchical rows. An empty object uses row.kids.
filterstring—A tree's filter field, labelled this.
expand((row: T) => unknown)—What opens under a row.
bulk((rows: string[]) => unknown)—The bar's actions, given the keys of the rows in the range.
page{ size: number; }—Rows per page.
exportNamestring | false—The CSV's name. false removes Export.
actionsunknown—Your own controls in the toolbar, before Filters, Group, Columns and Export.
maxHeightstring—A CSS height past which the rows scroll under a sticky header, such as 480px.
EventTypeDefaultDescription
onSaveView((name: string, view: GridView) => void)—Save view pressed: the new view's name (My view 1) and its columns, filters, sort and grouping, for you to keep.
onEdit((row: T, column: string, value: unknown) => void)—A cell edit committed: the row, the column's id and the new value. A column takes edits when it has edit.
onPaste((changes: GridPaste<T>[]) => void)—A validated paste, as one change. Default each cell through onEdit.

Parts

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

bulkfootersr-only

RelatedTablePivot tableScroll grid