Skip to content
kerfjsGitHub

Kanban

A board of cards in columns, moved by dragging, by keyboard or from a menu.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
import { state } from '@kerf-js/core'import { Kanban } from '@kerf-js/veneer'import type {  KanbanCardData,  KanbanColumn} from '@kerf-js/veneer'export function Board() {  const columns = state(BOARD)  return (    <Kanban      label="Release board"      columns={columns}      height="460px"    />  )}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 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,  }}const BOARD: KanbanColumn[] = [  {    id: 'todo',    title: 'To do',    color: 8,    cards: [      card('t1', 'Rotate the edge cache keys', {        label: 'Ops',        color: 2,        who: 'Jun',        meta: 'Nov 4',      }),      card('t2', 'Add Apple Pay to the receipts', {        label: 'Web',        color: 1,        who: 'Tomás',        meta: 'Nov 6',      }),      card('t3', 'Write the ranking rollout notes', {        label: 'Docs',        color: 5,        who: 'Lena',      }),    ],  },  {    id: 'doing',    title: 'In progress',    color: 1,    limit: 3,    cards: [      card('t4', 'Retry timed out upstream calls', {        label: 'Mobile',        color: 3,        who: 'Sam',        meta: 'Nov 2',      }),      card('t5', 'Rank exact matches first', {        label: 'Search',        color: 4,        who: 'Lena',        meta: 'Nov 3',      }),    ],  },  {    id: 'review',    title: 'In review',    color: 4,    cards: [      card('t6', 'Prorate seats on plan change', {        label: 'Billing',        color: 6,        who: 'Mara',      }),    ],  },  {    id: 'done',    title: 'Done',    color: 7,    cards: [      card('t7', 'Cache product images at the edge', {        label: 'Web',        color: 1,        who: 'Priya',      }),      card('t8', 'Lower the stale window to 30s', {        label: 'Ops',        color: 2,        who: 'Jun',      }),    ],  },]

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
Height
Codeupdates as you change the controls
import { Kanban } from '@kerf-js/veneer'<Kanban label="Release board" columns={columns} height="460px" />

Usage

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

Saving a move

onMove gives you the card, the column and the place, to send to your server.

tsx
import { state } from '@kerf-js/core'import { Kanban, Stack, Text } from '@kerf-js/veneer'import type {  KanbanCardData,  KanbanColumn} from '@kerf-js/veneer'export function Moves() {  const columns = state(BOARD)  const last = state(    'Move a card. onMove reports where it landed.',  )  const moved = (    id: string,    column: string,    index: number,  ) =>    (last.value =      `${id} moved to ${column}, ` +      `position ${index + 1}.`)  return (    <Stack gap="related">      <Text subtle>{last.value}</Text>      <Kanban        label="Release board"        columns={columns}        onMove={moved}        height="420px"      />    </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',  },]type Project = (typeof PROJECTS)[number]type Spend = {  id: string  name: string  deploys: number  cost: number  kids?: Spend[] | undefined  hasChildren?: boolean}const leaf = (p: Project): Spend => ({  id: p.id,  name: p.name,  deploys: p.deploys,  cost: p.cost,})const team = (  name: string,  ...ids: string[]): Spend => ({  id: name,  name,  deploys: 0,  cost: 0,  kids: PROJECTS.filter((p) => ids.includes(p.id)).map(    leaf,  ),})const latency = (r: number, h: number) =>  40 + (((r + 3) * 37 + h * h * 11) % 260)const 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,  }}const BOARD: KanbanColumn[] = [  {    id: 'todo',    title: 'To do',    color: 8,    cards: [      card('t1', 'Rotate the edge cache keys', {        label: 'Ops',        color: 2,        who: 'Jun',        meta: 'Nov 4',      }),      card('t2', 'Add Apple Pay to the receipts', {        label: 'Web',        color: 1,        who: 'Tomás',        meta: 'Nov 6',      }),      card('t3', 'Write the ranking rollout notes', {        label: 'Docs',        color: 5,        who: 'Lena',      }),    ],  },  {    id: 'doing',    title: 'In progress',    color: 1,    limit: 3,    cards: [      card('t4', 'Retry timed out upstream calls', {        label: 'Mobile',        color: 3,        who: 'Sam',        meta: 'Nov 2',      }),      card('t5', 'Rank exact matches first', {        label: 'Search',        color: 4,        who: 'Lena',        meta: 'Nov 3',      }),    ],  },  {    id: 'review',    title: 'In review',    color: 4,    cards: [      card('t6', 'Prorate seats on plan change', {        label: 'Billing',        color: 6,        who: 'Mara',      }),    ],  },  {    id: 'done',    title: 'Done',    color: 7,    cards: [      card('t7', 'Cache product images at the edge', {        label: 'Web',        color: 1,        who: 'Priya',      }),      card('t8', 'Lower the stale window to 30s', {        label: 'Ops',        color: 2,        who: 'Jun',      }),    ],  },]

Adding cards

onAdd shows Add card. You add it to your state, so the id is yours.

tsx
import { state } from '@kerf-js/core'import { Kanban } from '@kerf-js/veneer'import type {  KanbanCardData,  KanbanColumn} from '@kerf-js/veneer'export function Add() {  const columns = state(BOARD.slice(0, 3))  let made = 0  const add = (column: string, title: string) => {    const next = { id: `new-${made++}`, title }    columns.value = columns.value.map((c) =>      c.id === column        ? { ...c, cards: [...c.cards, next] }        : c,    )  }  return (    <Kanban      label="Release board"      columns={columns}      onAdd={add}      height="440px"    />  )}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 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,  }}const BOARD: KanbanColumn[] = [  {    id: 'todo',    title: 'To do',    color: 8,    cards: [      card('t1', 'Rotate the edge cache keys', {        label: 'Ops',        color: 2,        who: 'Jun',        meta: 'Nov 4',      }),      card('t2', 'Add Apple Pay to the receipts', {        label: 'Web',        color: 1,        who: 'Tomás',        meta: 'Nov 6',      }),      card('t3', 'Write the ranking rollout notes', {        label: 'Docs',        color: 5,        who: 'Lena',      }),    ],  },  {    id: 'doing',    title: 'In progress',    color: 1,    limit: 3,    cards: [      card('t4', 'Retry timed out upstream calls', {        label: 'Mobile',        color: 3,        who: 'Sam',        meta: 'Nov 2',      }),      card('t5', 'Rank exact matches first', {        label: 'Search',        color: 4,        who: 'Lena',        meta: 'Nov 3',      }),    ],  },  {    id: 'review',    title: 'In review',    color: 4,    cards: [      card('t6', 'Prorate seats on plan change', {        label: 'Billing',        color: 6,        who: 'Mara',      }),    ],  },  {    id: 'done',    title: 'Done',    color: 7,    cards: [      card('t7', 'Cache product images at the edge', {        label: 'Web',        color: 1,        who: 'Priya',      }),      card('t8', 'Lower the stale window to 30s', {        label: 'Ops',        color: 2,        who: 'Jun',      }),    ],  },]

Over its limit

limit on a column shows the count against it, and warns when it goes over.

tsx
import { state } from '@kerf-js/core'import { Kanban } from '@kerf-js/veneer'import type {  KanbanCardData,  KanbanColumn} from '@kerf-js/veneer'export function Limit() {  const columns = state(OVER)  return (    <Kanban      label="Over its limit"      columns={columns}      height="360px"    />  )}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 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,  }}const OVER: KanbanColumn[] = [  {    id: 'todo',    title: 'To do',    color: 8,    cards: [      card('o1', 'Rotate the edge cache keys', {        label: 'Ops',        color: 2,        who: 'Jun',      }),    ],  },  {    id: 'doing',    title: 'In progress',    color: 1,    limit: 2,    cards: [      card('o2', 'Retry timed out upstream calls', {        label: 'Mobile',        color: 3,        who: 'Sam',      }),      card('o3', 'Rank exact matches first', {        label: 'Search',        color: 4,        who: 'Lena',      }),      card('o4', 'Prorate seats on plan change', {        label: 'Billing',        color: 6,        who: 'Mara',      }),    ],  },]

2,000 cards a column

Each column is a window on its cards, so a long one costs what a short one does.

tsx
import { state } from '@kerf-js/core'import { Kanban } from '@kerf-js/veneer'import type { KanbanColumn } from '@kerf-js/veneer'export function Long() {  const columns = state(SPRINT)  return (    <Kanban      label="2,000 cards a column"      columns={columns}      height="420px"    />  )}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',  },]const SPRINT: KanbanColumn[] = [  'Backlog',  'Next',  'Doing',].map((title, c) => ({  id: title,  title,  color: c + 1,  cards: Array.from({ length: 2000 }, (_, i) => ({    id: `${title}-${i}`,    title: `${DEPLOYS[(i + c) % 6]!.message} (${i + 1})`,    meta: DEPLOYS[(i + c) % 6]!.project,  })),}))

When to use

Use it for

  • Work that moves through stages
  • Seeing at a glance where things are stuck

Reach for something else

  • Table, when people sort and filter rather than move
  • Checklist, when there is only done and not done

Accessibility

Alt+arrows
Moves the focused card
Space
Picks up or drops the card
←→↑↓
Moves a card that's picked up
Esc
Puts a card back
  • There are three ways to move a card, and only one needs a pointer. The Move button lists every destination.
  • Every move is announced with where the card landed.
  • On a touch screen the Move button opens a sheet.

API Reference

PropTypeDefaultDescription
columnsKanbanColumn[] | State<KanbanColumn[]>—A state, or a list the board follows and onMove reports to.
heightstring—The board's height. Default 560px.
labelstring'Board'Names the board.
EventTypeDefaultDescription
onMove((id: string, column: string, index: number) => void)—Called with the card's id, the column and the index.
onAdd((column: string, title: string) => void)—Shows Add card in each column, and is called with the column and the title.
onDrift((index: number, predicted: number, actual: number) => void)—Called when a card's measured height differs from the estimate, with both heights. For tuning.

Parts

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

sr-only

RelatedTableChecklistVirtual list