Skip to content
weftGitHub

Kanban

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

SourcePackage @weft-js/warpPlayground

Preview

tsx
import { state } from '@weft-js/core'import { Kanban } from '@weft-js/warp'import type { KanbanCardData, KanbanColumn } from '@weft-js/warp'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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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) % 260const initials = (name: string) => PEOPLE.find(p => p.name.startsWith(name))?.initialsconst card = (id: string, title: string, label: string, color: number, who: string, meta?: string): KanbanCardData => ({ 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', 'Ops', 2, 'Jun', 'Nov 4'),    card('t2', 'Add Apple Pay to the receipts', 'Web', 1, 'Tomás', 'Nov 6'),    card('t3', 'Write the ranking rollout notes', 'Docs', 5, 'Lena'),  ] },  { id: 'doing', title: 'In progress', color: 1, limit: 3, cards: [    card('t4', 'Retry timed out upstream calls', 'Mobile', 3, 'Sam', 'Nov 2'),    card('t5', 'Rank exact matches first', 'Search', 4, 'Lena', 'Nov 3'),  ] },  { id: 'review', title: 'In review', color: 4, cards: [card('t6', 'Prorate seats on plan change', 'Billing', 6, 'Mara')] },  { id: 'done', title: 'Done', color: 7, cards: [    card('t7', 'Cache product images at the edge', 'Web', 1, 'Priya'),    card('t8', 'Lower the stale window to 30s', 'Ops', 2, 'Jun'),  ] },]

Installation

npm install @weft-js/warp

Usage

tsx
import { Kanban } from '@weft-js/warp'

Saving a move

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

tsx
import { state } from '@weft-js/core'import { Kanban, Stack, Text } from '@weft-js/warp'import type { KanbanCardData, KanbanColumn } from '@weft-js/warp'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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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) % 260const initials = (name: string) => PEOPLE.find(p => p.name.startsWith(name))?.initialsconst card = (id: string, title: string, label: string, color: number, who: string, meta?: string): KanbanCardData => ({ 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', 'Ops', 2, 'Jun', 'Nov 4'),    card('t2', 'Add Apple Pay to the receipts', 'Web', 1, 'Tomás', 'Nov 6'),    card('t3', 'Write the ranking rollout notes', 'Docs', 5, 'Lena'),  ] },  { id: 'doing', title: 'In progress', color: 1, limit: 3, cards: [    card('t4', 'Retry timed out upstream calls', 'Mobile', 3, 'Sam', 'Nov 2'),    card('t5', 'Rank exact matches first', 'Search', 4, 'Lena', 'Nov 3'),  ] },  { id: 'review', title: 'In review', color: 4, cards: [card('t6', 'Prorate seats on plan change', 'Billing', 6, 'Mara')] },  { id: 'done', title: 'Done', color: 7, cards: [    card('t7', 'Cache product images at the edge', 'Web', 1, 'Priya'),    card('t8', 'Lower the stale window to 30s', 'Ops', 2, 'Jun'),  ] },]

Adding cards

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

tsx
import { state } from '@weft-js/core'import { Kanban } from '@weft-js/warp'import type { KanbanCardData, KanbanColumn } from '@weft-js/warp'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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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) % 260const initials = (name: string) => PEOPLE.find(p => p.name.startsWith(name))?.initialsconst card = (id: string, title: string, label: string, color: number, who: string, meta?: string): KanbanCardData => ({ 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', 'Ops', 2, 'Jun', 'Nov 4'),    card('t2', 'Add Apple Pay to the receipts', 'Web', 1, 'Tomás', 'Nov 6'),    card('t3', 'Write the ranking rollout notes', 'Docs', 5, 'Lena'),  ] },  { id: 'doing', title: 'In progress', color: 1, limit: 3, cards: [    card('t4', 'Retry timed out upstream calls', 'Mobile', 3, 'Sam', 'Nov 2'),    card('t5', 'Rank exact matches first', 'Search', 4, 'Lena', 'Nov 3'),  ] },  { id: 'review', title: 'In review', color: 4, cards: [card('t6', 'Prorate seats on plan change', 'Billing', 6, 'Mara')] },  { id: 'done', title: 'Done', color: 7, cards: [    card('t7', 'Cache product images at the edge', 'Web', 1, 'Priya'),    card('t8', 'Lower the stale window to 30s', 'Ops', 2, 'Jun'),  ] },]

Over its limit

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

tsx
import { state } from '@weft-js/core'import { Kanban } from '@weft-js/warp'import type { KanbanCardData, KanbanColumn } from '@weft-js/warp'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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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) % 260const initials = (name: string) => PEOPLE.find(p => p.name.startsWith(name))?.initialsconst card = (id: string, title: string, label: string, color: number, who: string, meta?: string): KanbanCardData => ({ 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', 'Ops', 2, 'Jun')] },  { id: 'doing', title: 'In progress', color: 1, limit: 2, cards: [    card('o2', 'Retry timed out upstream calls', 'Mobile', 3, 'Sam'),    card('o3', 'Rank exact matches first', 'Search', 4, 'Lena'),    card('o4', 'Prorate seats on plan change', 'Billing', 6, '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 '@weft-js/core'import { Kanban } from '@weft-js/warp'import type { KanbanColumn } from '@weft-js/warp'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

Playground

Height
import { Kanban } from '@weft-js/warp'<Kanban label="Release board" columns={columns} height="460px" />

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