Skip to content
kerfjsGitHub

PivotTable

Sums rows across two dimensions, with shaded cells and totals both ways.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
import { PivotTable } from '@kerf-js/veneer'import type { PivotMeasure } from '@kerf-js/veneer'export function Regions() {  return (  <PivotTable    caption="Projects by region and status"    rows={PROJECTS}    row={(r) => r.region}    column={(r) => r.status}    rowHeader="Region"    measures={MEASURES}  />  )}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 latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)const MEASURES: PivotMeasure<Project>[] = [  {    id: 'cost',    label: 'Cost',    value: (p) => p.cost,    format: money,  },  {    id: 'deploys',    label: 'Deploys',    value: (p) => p.deploys,  },  { id: 'count', label: 'Projects', value: () => 1 },]

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
Codeupdates as you change the controls
import { PivotTable } from '@kerf-js/veneer'<PivotTable caption="Projects by region and status" rows={projects} row={p => p.region} column={p => p.status} measures={measures} rowHeader="Region" />

Usage

tsx
import { PivotTable } from '@kerf-js/veneer'<PivotTable  caption="Projects by region and status"  rows={PROJECTS}  row={(r) => r.region}  column={(r) => r.status}  rowHeader="Region"  measures={MEASURES}/>

One measure

One measure, no switch. format draws the values.

tsx
import { PivotTable } from '@kerf-js/veneer'import type { PivotMeasure } from '@kerf-js/veneer'export function Quarters() {  return (  <PivotTable    caption="Quarterly spend by project"    rows={QUARTERS}    row={(q) => q.project}    column={(q) => q.quarter}    rowHeader="Project"    measures={ONE}  />  )}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 ONE: PivotMeasure<  (typeof QUARTERS)[number]>[] = [  {    id: 'cost',    label: 'Cost',    value: (q) => q.cost,    format: money,  },]const QUARTERS = PROJECTS.slice(0, 6).flatMap(  (p) =>    ['Q1', 'Q2', 'Q3', 'Q4'].map((quarter, n) => ({      project: p.name,      quarter,      cost: Math.round(p.cost * (0.8 + n * 0.11)),    })),)

People by team and role

Use row and column to say what to group by, and measures for the figure in each cell.

tsx
import { PivotTable } from '@kerf-js/veneer'import type { PivotMeasure } from '@kerf-js/veneer'export function Teams() {  return (  <PivotTable    caption="People by team and role"    rows={PEOPLE}    row={(p) => p.team}    column={(p) => p.role}    rowHeader="Team"    measures={HEADS}  />  )}const PEOPLE = [  {    id: 'mara',    name: 'Mara Lindqvist',    email: 'mara@kerf.dev',    role: 'Owner',    team: 'Platform',    initials: 'ML',  },  {    id: 'jun',    name: 'Jun Tanaka',    email: 'jun@kerf.dev',    role: 'Admin',    team: 'Platform',    initials: 'JT',  },  {    id: 'priya',    name: 'Priya Raman',    email: 'priya@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'PR',  },  {    id: 'tomas',    name: 'Tomás Oliveira',    email: 'tomas@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'TO',  },  {    id: 'lena',    name: 'Lena Fischer',    email: 'lena@kerf.dev',    role: 'Member',    team: 'Data',    initials: 'LF',  },  {    id: 'noor',    name: 'Noor Haddad',    email: 'noor@kerf.dev',    role: 'Viewer',    team: 'Data',    initials: 'NH',  },  {    id: 'sam',    name: 'Sam Okafor',    email: 'sam@kerf.dev',    role: 'Member',    team: 'Mobile',    initials: 'SO',  },  {    id: 'ines',    name: 'Inés Vidal',    email: 'ines@kerf.dev',    role: 'Viewer',    team: 'Mobile',    initials: 'IV',  },]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]type Person = (typeof PEOPLE)[number]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 latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)const HEADS: PivotMeasure<Person>[] = [  { id: 'people', label: 'People', value: () => 1 },]const initials = (name: string) =>  PEOPLE.find((p) => p.name.startsWith(name))?.initials

When to use

Use it for

  • Comparing a number across two categories
  • Spotting where the cost or the traffic really sits

Reach for something else

  • HeatGrid, when the picture matters more than the values
  • DataGrid, when people need the rows themselves

Accessibility

  • It is a real table with row and column headers, and the shading only repeats what the numbers say.
  • The scroll area is focusable, so it can be scrolled by keyboard on a phone.

API Reference

PropTypeDefaultDescription
rowsreadonly T[]—The data, one object per row.
captionstring—Names the table for screen readers.
row(r: T) => string—Which row an item counts towards.
column(r: T) => string—Which column an item counts towards.
rowHeaderstring''The label above the first column.
measuresPivotMeasure<T>[]—What to sum: id, label, value, and an optional format.

Parts

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

viewportsr-onlyaggregate

RelatedData gridTableHeat grid