Table
Rows of data that people can sort, filter, select and page through.
Preview
import { state } from '@kerf-js/core'import { Badge, Stack, Table, Text} from '@kerf-js/veneer'import type { Column, KanbanCardData, TableSort} from '@kerf-js/veneer'export function Sortable() { const sort = state<TableSort | undefined>({ id: 'cost', dir: 'descending', }) return ( <Stack gap="related"> <Text subtle> Sorted by {sort.value?.id}, {sort.value?.dir}. </Text> <Table caption="Projects by cost" captionHidden rows={PROJECTS} columns={COLUMNS} sort={sort} picker={false} /> </Stack> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}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.
import { Table } from '@kerf-js/veneer'<Table caption="Projects" rows={projects} columns={columns} filter="Filter projects" />Usage
import { Table } from '@kerf-js/veneer'Filtering
filter adds the field and searches every column. Try "eu-west" or "Lena".
import { Badge, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Filter() { return ( <Table caption="Projects" captionHidden rows={PROJECTS} columns={COLUMNS} filter="Filter projects" picker={false} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Selection and bulk actions
Shift-click selects a range. bulk fills the bar, and these buttons really pause and delete.
import { state } from '@kerf-js/core'import { Badge, Button, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Select() { const rows = state(PROJECTS) const picked = state<Record<string, boolean>>({}) const pause = (keys: string[]) => { rows.value = rows.value.map((p) => keys.includes(p.id) ? { ...p, status: 'Paused' as const } : p, ) picked.value = {} } const remove = (keys: string[]) => { rows.value = rows.value.filter( (p) => !keys.includes(p.id), ) picked.value = {} } return ( <Table caption="Projects" captionHidden rows={rows} columns={COLUMNS} picker={false} selection={picked} bulk={(keys) => ( <> <Button size="sm" emphasis="outline" tone="neutral" icon="pause" onClick={() => pause(keys)} > Pause </Button> <Button size="sm" emphasis="outline" tone="critical" icon="trash" onClick={() => remove(keys)} > Delete </Button> </> )} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Pages
page slices the rows and adds the footer.
import { Badge, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Paged() { return ( <Table caption="Projects" captionHidden rows={PROJECTS} columns={COLUMNS} page={FIVE} picker={false} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]const FIVE = { size: 5 }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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Row details
Press a row. expand draws what opens.
import { Badge, Row, Stack, Table, Text} from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Details() { return ( <Table caption="Projects" captionHidden rows={PROJECTS} columns={COLUMNS} expand={details} picker={false} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]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>)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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Columns that give way
priority 2 hides below 760px of table width and 3 below 1000px. Hidden fields move into the row's details.
import { Badge, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Priority() { return ( <Table caption="Projects" captionHidden rows={PROJECTS} columns={RANKED} picker={false} /> )}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', },]const TONE_OF: Record< Status, 'positive' | 'info' | 'critical' | 'neutral'> = { Live: 'positive', Building: 'info', Failed: 'critical', Paused: 'neutral',}type Project = (typeof PROJECTS)[number]const badge = (p: { status: string }) => ( <Badge tone={TONE_OF[p.status as Status]} dot > {p.status} </Badge>)const RANKED: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, card: 'title', }, { id: 'status', header: 'Status', card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', priority: 2 }, { id: 'region', header: 'Region', priority: 2 }, { id: 'latency', header: 'Latency', numeric: true, priority: 3, }, { id: 'uptime', header: 'Uptime', numeric: true, priority: 3, },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Pinned and resizable columns
pin keeps a column in place while the rest scroll. Drag a header's edge to resize it.
import { Badge, Table } from '@kerf-js/veneer'import type { Column } from '@kerf-js/veneer'export function Pinned() { return ( <Table caption="Every field of every project" captionHidden rows={PROJECTS} columns={WIDE} phone="columns" /> )}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 WIDE: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, pin: true, resize: true, }, { id: 'status', header: 'Status', resize: true, cell: badge, }, { id: 'owner', header: 'Owner', resize: true }, { id: 'region', header: 'Region' }, { id: 'deploys', header: 'Deploys', numeric: true }, { id: 'uptime', header: 'Uptime', numeric: true }, { id: 'latency', header: 'Latency', numeric: true }, { id: 'cost', header: 'Cost', numeric: true, cell: (p) => money(p.cost), }, { id: 'updated', header: 'Updated' },]const latency = (r: number, h: number) => 40 + (((r + 3) * 37 + h * h * 11) % 260)A fixed height
maxHeight scrolls the rows under a sticky header.
import { Badge, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Sticky() { return ( <Table caption="Projects" captionHidden rows={PROJECTS} columns={COLUMNS} maxHeight="280px" picker={false} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Inline editing
edit turns text into the value, validate refuses a bad one, and onEdit saves it.
import { state } from '@kerf-js/core'import { Stack, Table, Text } from '@kerf-js/veneer'import type { Column } from '@kerf-js/veneer'export function Edit() { const rows = state(PROJECTS.slice(0, 5)) const last = state( 'Double-click a cell, or focus it and press Enter.', ) const save = ( row: Project, column: string, value: unknown, ) => { rows.value = rows.value.map((p) => p.id === row.id ? { ...p, [column]: value } : p, ) last.value = `Saved ${column} for ${row.name}.` } return ( <Stack gap="related"> <Text subtle>{last.value}</Text> <Table caption="Budgets" captionHidden rows={rows} columns={PRICED} onEdit={save} picker={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 PRICED: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, edit: true, validate: (v) => String(v).trim() !== '' || 'A project needs a name', }, { id: 'owner', header: 'Owner', edit: true }, { id: 'cost', header: 'Budget', numeric: true, cell: (p) => money(p.cost), 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)Live numbers
A live cell flashes up or down as its value changes. Only that cell updates.
import { onCleanup, onMount, state } from '@kerf-js/core'import { Badge, Table } from '@kerf-js/veneer'import type { Column } from '@kerf-js/veneer'export function Live() { const ms = state( Object.fromEntries( WATCHED.map((p) => [p.id, p.latency]), ), ) const columns: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true }, { id: 'status', header: 'Status', cell: badge }, { id: 'latency', header: 'Latency (ms)', numeric: true, live: true, value: (p) => ms.get(p.id), }, ] const tick = () => { const next = { ...ms.value } for (const id in next) next[id] = Math.max( 12, next[id]! + Math.round(Math.random() * 24 - 12), ) ms.value = next } onMount(() => { const timer = setInterval(tick, 1200) onCleanup(() => clearInterval(timer)) }) return ( <Table caption="Latency now" captionHidden rows={WATCHED} columns={columns} picker={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 badge = (p: { status: string }) => ( <Badge tone={TONE_OF[p.status as Status]} dot > {p.status} </Badge>)const WATCHED = PROJECTS.slice(0, 5)const latency = (r: number, h: number) => 40 + (((r + 3) * 37 + h * h * 11) % 260)Loading
Comes by itself with a Resource. status forces it.
import { Badge, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Loading() { return ( <Table caption="Projects" captionHidden rows={PROJECTS} columns={COLUMNS} status="loading" picker={false} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Empty
Use empty to say what to show when there are no rows.
import { Badge, Button, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Empty() { return ( <Table caption="Projects" captionHidden rows={NONE} columns={COLUMNS} picker={false} empty={{ title: 'No projects yet', text: 'Connect a repository and your first deploy ' + 'runs in about a minute.', action: <Button icon="plus">New project</Button>, }} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]const NONE: Project[] = []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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Error
Retry calls onRetry, and here it succeeds.
import { state } from '@kerf-js/core'import { Badge, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Error() { const failed = state(true) return ( <Table caption="Projects" captionHidden rows={WATCHED} columns={COLUMNS} picker={false} status={failed.value ? 'error' : undefined} error={{ title: 'Projects could not load', text: 'The API timed out. Nothing was lost.', }} onRetry={() => (failed.value = false)} /> )}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', },]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 COLUMNS: Column<Project>[] = [ { id: 'name', header: 'Project', rowHeader: true, sort: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'owner', header: 'Owner', sort: true }, { id: 'region', header: 'Region', sort: true }, { id: 'deploys', header: 'Deploys', numeric: true, sort: true, }, { id: 'cost', header: 'Cost', numeric: true, sort: true, cell: (p) => money(p.cost), },]const WATCHED = PROJECTS.slice(0, 5)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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Recent deploys
Use filter to add a search box, density to set how tight the rows sit, and actions for buttons above the table.
import { Badge, Button, Table, Text} from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Deploys() { return ( <Table caption="Recent deploys" rows={DEPLOYS} columns={DEPLOY_COLUMNS} filter="Filter deploys" density="cozy" actions={<Button icon="rocket">Deploy</Button>} /> )}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', },]const TONE_OF: Record< Status, 'positive' | 'info' | 'critical' | 'neutral'> = { Live: 'positive', Building: 'info', Failed: 'critical', Paused: 'neutral',}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', },]type Project = (typeof PROJECTS)[number]const badge = (p: { status: string }) => ( <Badge tone={TONE_OF[p.status as Status]} dot > {p.status} </Badge>)type Deploy = (typeof DEPLOYS)[number]const DEPLOY_COLUMNS: Column<Deploy>[] = [ { id: 'message', header: 'Change', rowHeader: true, card: 'title', }, { id: 'status', header: 'Status', sort: true, card: 'badge', cell: badge, }, { id: 'project', header: 'Project', sort: true }, { id: 'author', header: 'Author', sort: true, priority: 2, }, { id: 'commit', header: 'Commit', priority: 2, cell: (d) => ( <Text inline role="code" > {d.commit} </Text> ), }, { id: 'at', header: 'When', sort: true, value: (d) => d.at.replace('T', ' '), },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}Team members
Use selection to let people tick rows, and sort on a column to let them order by it.
import { Badge, Table } from '@kerf-js/veneer'import type { Column, KanbanCardData} from '@kerf-js/veneer'export function Team() { return ( <Table caption="Team" rows={PEOPLE} columns={TEAM_COLUMNS} filter="Find a person" selection picker={false} /> )}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]const TEAM_COLUMNS: Column<Person>[] = [ { id: 'name', header: 'Name', rowHeader: true, sort: true, card: 'title', }, { id: 'email', header: 'Email', card: 'hide' }, { id: 'team', header: 'Team', sort: true }, { id: 'role', header: 'Role', sort: true, card: 'badge', cell: (p) => ( <Badge tone={p.role === 'Owner' ? 'accent' : 'neutral'} > {p.role} </Badge> ), },]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 initials = (name: string) => PEOPLE.find((p) => p.name.startsWith(name))?.initialsconst card = ( id: string, title: string, tag: { label: string color: number who: string meta?: string },): KanbanCardData => { const { label, color, who, meta } = tag return { id, title, label, color, assignee: initials(who), meta, }}When to use
Use it for
- Records people scan and compare
- Lists that need sorting, filtering or bulk actions
Reach for something else
- DataGrid, when people work cell by cell, like a spreadsheet
- VirtualList, when there are many thousands of rows
- ScrollGrid, when there are many columns as well as rows
Accessibility
- Tab
- Moves through the headers, checkboxes and row buttons
- Enter
- On a header, sorts. On an editable cell, edits
- Esc
- Cancels an edit
- Shiftclick
- Selects a range of rows
captionis required: it names the table, andcaptionHiddenkeeps it for screen readers only.- Below 600px of table width rows become cards, unless
phone="columns". Roles stay on the elements so it still reads as a table.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
rows | readonly T[] | State<readonly T[]> | Derived<readonly T[]> | Resource<re… | — | A list, a state, or a Resource. Given a list it sorts, filters and pages by itself. |
caption | string | — | Names the table. It stays in the accessibility tree when captionHidden. |
captionHidden | boolean | false | Hides the caption on screen. Screen readers still hear it. |
columns | Column<T>[] | — | What each column shows. See id, header, sort, cell, numeric, priority, edit in the examples. |
rowKey | string | ((row: T) => string) | — | The field that identifies a row, or how to get it. Default id. |
phone | "columns" | "cards" | — | Below 600px: cards, or columns to keep scrolling sideways. |
density | "compact" | "cozy" | "comfortable" | — | How tight the rows are. |
filter | string | — | Show the filter field, labelled this. |
query | State<string> | — | A state holding the filter text, so you can read or set it. |
sort | State<TableSort | undefined> | — | A state holding the sorted column and direction, so you can read or set it. |
server | boolean | false | The rows arrive sorted, filtered and paged, so the table only reports what was asked. |
selection | boolean | State<Record<string, boolean>> | — | true, or a state to hold the picked keys yourself. |
bulk | ((keys: string[]) => unknown) | — | The bulk bar's actions. |
page | { size: number; } | — | Rows per page. |
expand | ((row: T) => unknown) | — | What opens under a row. Default a list of every column. |
details | "sheet" | "inline" | — | sheet opens details in a panel below 1000px. |
actions | unknown | — | The toolbar's own actions. |
picker | boolean | false | The Columns button. On by default. |
hiddenColumns | readonly string[] | State<readonly string[]> | — | Columns hidden from the start, in the server's HTML too. Bind a State to keep what people choose. |
status | TableStatus | — | Forces a state; a Resource's own is used otherwise. |
empty | { title?: unknown; text?: unknown; action?: unknown; } | — | What to show when there are no rows: a title, a line of text and an action. |
error | { title?: unknown; text?: unknown; } | — | A message that marks the field invalid and says why. |
maxHeight | string | — | A CSS height past which the body scrolls under a sticky header, such as 480px. |
heading | number | — | The outline level of the table's state and sheet headings, under the page's h1. |
| Event | Type | Default | Description |
|---|---|---|---|
onSortChange | ((sort: TableSort) => void) | — | Called with the column and direction when someone sorts. |
onSelectionChange | ((keys: string[]) => void) | — | Called with the keys of every selected row after each change. |
onPageChange | ((to: unknown, reason: "next" | "prev") => void) | — | Called on Next or Previous, with the page or server cursor to load. |
onEdit | ((row: T, column: string, value: unknown) => void) | — | Called with the row, the column's id and the new value when a cell edit is committed. |
onRetry | (() => void) | — | Called by the error state's Retry. A Resource refreshes itself as well. |