Skip to content
kerfjsGitHub

Table

Rows of data that people can sort, filter, select and page through.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
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/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
Presets
Density
Phone
Codeupdates as you change the controls
import { Table } from '@kerf-js/veneer'<Table caption="Projects" rows={projects} columns={columns} filter="Filter projects" />

Usage

tsx
import { Table } from '@kerf-js/veneer'

Filtering

filter adds the field and searches every column. Try "eu-west" or "Lena".

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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
  • caption is required: it names the table, and captionHidden keeps 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

PropTypeDefaultDescription
rowsreadonly 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.
captionstring—Names the table. It stays in the accessibility tree when captionHidden.
captionHiddenbooleanfalseHides the caption on screen. Screen readers still hear it.
columnsColumn<T>[]—What each column shows. See id, header, sort, cell, numeric, priority, edit in the examples.
rowKeystring | ((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.
filterstring—Show the filter field, labelled this.
queryState<string>—A state holding the filter text, so you can read or set it.
sortState<TableSort | undefined>—A state holding the sorted column and direction, so you can read or set it.
serverbooleanfalseThe rows arrive sorted, filtered and paged, so the table only reports what was asked.
selectionboolean | 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.
actionsunknown—The toolbar's own actions.
pickerbooleanfalseThe Columns button. On by default.
hiddenColumnsreadonly string[] | State<readonly string[]>—Columns hidden from the start, in the server's HTML too. Bind a State to keep what people choose.
statusTableStatus—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.
maxHeightstring—A CSS height past which the body scrolls under a sticky header, such as 480px.
headingnumber—The outline level of the table's state and sheet headings, under the page's h1.
EventTypeDefaultDescription
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.

RelatedData gridPagination barVirtual list