Kanban
A board of cards in columns, moved by dragging, by keyboard or from a menu.
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/veneerTry 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
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
| Prop | Type | Default | Description |
|---|---|---|---|
columns | KanbanColumn[] | State<KanbanColumn[]> | — | A state, or a list the board follows and onMove reports to. |
height | string | — | The board's height. Default 560px. |
label | string | 'Board' | Names the board. |
| Event | Type | Default | Description |
|---|---|---|---|
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