ComponentsEditors & drag-and-drop

Kanban

Items in stage columns: deal pipelines, editorial workflows, recruiting, support queues. Drag between columns, or move with the card menu and Alt + arrow keys. Column totals, WIP limits, collapsible columns, swimlanes, quick add, move rules with reasons, optimistic saves with rollback, and virtualized columns for thousands of cards.

Sortable (multi-container) + @tanstack/react-virtual sortable/kanban.tsx, sortable/kanban-core.ts, sortable/sortable.tsxtheme · material · accent · density · font

Qualified

2
$60,500 · weighted $6,050

Discovery

2
$73,550 · weighted $18,388

Proposal

33 of 4
$118,400 · weighted $59,200

Negotiation

1
$31,200 · weighted $23,400

Closed won

1
$15,000

Installation

$ pnpm add @vamos-ui/react

The whole kit as one package — get updates by bumping the version. Add the styles to globals.css: @import "@vamos-ui/react/styles.css";

Usage

import { Kanban, KanbanCardTitle, applyKanbanMove, sumBy, type Stage } from "@vamos-ui/react/sortable";

const stages: Stage[] = [
  { key: "lead", label: "Lead", tone: "chart-1", probability: 0.1 },
  { key: "proposal", label: "Proposal", tone: "chart-3", probability: 0.5, wipLimit: 6 },
  { key: "won", label: "Won", tone: "success", category: "done", probability: 1 },
];

<Kanban
  aria-label="Sales pipeline"
  stages={stages}
  items={deals}
  getStageKey={(d) => d.stage}
  summary={(items) => format.currency(sumBy(items, (d) => d.value))}
  renderCard={(d) => <KanbanCardTitle>{d.title}</KanbanCardTitle>}
  onMove={(e) => setDeals((all) => applyKanbanMove(all, e, {
    getId: (d) => d.id,
    getStageKey: (d) => d.stage,
    update: (d, stage) => ({ ...d, stage }),
  }))}
/>

Columns are role="group" named "Proposal, 12 items" with the summary as description. Cards are focusable articles named by getLabel; inner links and buttons keep their own focus.

Keyboard: on a focused card, Space or Enter lifts it (arrow keys move across columns, Space drops, Esc cancels). Alt + ← / → moves to the adjacent stage (mirrored in RTL), Alt + ↑ / ↓ within the column. The card menu has Move up / down and Move to ▸ every stage. Every move is announced and focus stays on the card.

Cards never blur, even in glass mode. The dragged clone is an opaque popover; blur turns off page-wide while dragging.

Stage is shared with Funnel, Approval flow and Status picker. Money summaries go through useFormat().currency with decimal strings.

A Toaster must be mounted (App shell has one) for the error and blocked-move toasts.

Examples

Content workflow

A CMS editorial board with compact cards and narrow columns. Ideas starts collapsed (a vertical label with the count; it still accepts drops). In review warns above 3 stories. Quick add at the bottom of every column.

Draft

2

Why the four-day week stuck

Work

Inside the night market kitchens

Food

In review

4Over the work-in-progress limit of 3

A beginner's guide to rent caps

Housing

The city's last bookbinders

Culture

Cycling lanes, one year on

City

Weekend: what's on

Culture

Scheduled

1

Election explainer: the new districts

PoliticsThu 07:00 AM

Published

1

Harbour clean-up, in pictures

City

Swimlanes

HR recruiting, one lane per recruiter. Each lane × stage cell is its own drop target named "Interview, Dilan Çelik, 2 items"; moving between lanes reassigns the candidate.

Applied

2

Phone screen

1

Interview

22 of 2

Offer

1

Hired

1
4

Aylin Yıldız

Product designer

Jakub Nowak

Backend engineerReferral

Grace Liu

Backend engineer
Drop items here

Lucía Herrera

Product designerReferral
3

Kenji Watanabe

Support lead
Drop items here

Samuel Adeyemi

Data analystReferral

Ines Moreau

Account executive
Drop items here

Move rules and confirmation

canMove returns a reason: the target column turns red while dragging, the menu item is disabled with the reason, and a blocked drop is announced. beforeMove opens a Lost reason dialog and cancels the move when it is dismissed.

Proposal

1

Harborview Hotels — guest app

$54,000.00

Negotiation

2

Pinecrest Schools — LMS pilot

No value yet

Atlas Freight — tracking portal

$72,500.00

Won

0
Drop items here

Lost

0
Drop items here

With a filter bar

The toolbar slot holds a Filter bar with search. Pass the filtered items plus filtered so empty columns read "No items match"; apply moves to the full list.

New

2

Card reader disconnects after update

SUP-2041urgent

Invoice PDF shows wrong VAT number

SUP-2039high

Open

22 of 5

Can't invite a second admin

SUP-2033normal

Export to CSV times out on 50k rows

SUP-2030high

Waiting on customer

2

Wrong opening hours on the booking page

SUP-2027low

Refund stuck in processing

SUP-2019urgent

Solved

1

Dark mode logo is invisible

SUP-2012normal

2,000 cards, virtualized

Columns above 60 cards render only what is in view. The dragged card stays mounted while it scrolls out of sight; set a board height so columns scroll.

Received

1,500

Picking

300

Packed

150

Shipped

50

Order #50160

Goran Petrović1 item

Order #50161

Hiroshi Lindqvist2 items

Order #50162

Isla Murray3 items

Order #50163

Jonas Aydın4 items

Order #50164

Kerem Şahin5 items

Order #50165

Leila Larsen6 items

Order #50166

Amara Okafor1 item

Order #50167

Bastian Tanaka2 items

Order #50168

Chiara Romano3 items

Order #50169

Deniz Weber4 items

Order #50170

Emeka Mensah5 items

Order #50171

Freya Haddad6 items

Order #50172

Goran Petrović1 item

Order #50173

Hiroshi Lindqvist2 items

Order #50174

Isla Murray3 items

Order #50175

Jonas Aydın4 items

Order #50176

Kerem Şahin5 items

Order #50177

Leila Larsen6 items

Order #50178

Amara Okafor1 item

Order #50179

Bastian Tanaka2 items

Order #50180

Chiara Romano3 items

Order #50181

Deniz Weber4 items

Order #50182

Emeka Mensah5 items

Order #50183

Freya Haddad6 items

Order #50184

Goran Petrović1 item

Order #50185

Hiroshi Lindqvist2 items

Order #50186

Isla Murray3 items

Order #50187

Jonas Aydın4 items

Order #50188

Kerem Şahin5 items

Order #50189

Leila Larsen6 items

Order #50190

Amara Okafor1 item

Order #50191

Bastian Tanaka2 items

Order #50192

Chiara Romano3 items

Order #50193

Deniz Weber4 items

Order #50194

Emeka Mensah5 items

Order #50195

Freya Haddad6 items

Order #50196

Goran Petrović1 item

Order #50197

Hiroshi Lindqvist2 items

Order #50198

Isla Murray3 items

Order #50199

Jonas Aydın4 items

Order #50200

Kerem Şahin5 items

Order #50201

Leila Larsen6 items

Order #50202

Amara Okafor1 item

Order #50203

Bastian Tanaka2 items

Order #50204

Chiara Romano3 items

Order #50205

Deniz Weber4 items

Order #50206

Emeka Mensah5 items

Order #50207

Freya Haddad6 items

Order #50208

Goran Petrović1 item

Order #50209

Hiroshi Lindqvist2 items

Loading, saving and errors

loading shows three skeleton cards per column. An async onMove keeps the card in its new place, dimmed and aria-busy; a rejection snaps it back and a toast offers Retry.

To do

1

Write the Q4 onboarding email

In progress

3Over the work-in-progress limit of 2

Audit role permissions

Fix broken links on /pricing

Translate the help centre to Turkish

Done

0
Drop items here

API

Kanban

PropTypeDefault
stages
Columns in order. Stage: { key, label, tone?, category?, probability?, wipLimit?, collapsed? }.
Stage[]—
items
Cards. Column order follows the order of items.
T[]—
getStageKey
Stage key of an item.
(item) => string—
renderCard
Card content inside the kit's card frame. Also renders the dragged clone (overlay: true).
(item, { isDragging, compact, overlay, saving, stage }) => ReactNode—
onMove
Once per drop, menu move or shortcut. e: { id, from, to, index, fromIndex, lane?, fromLane? }. A promise keeps the move optimistic; a rejection rolls it back with a Retry toast.
(e: KanbanMoveEvent) => void | Promise<void>—
getId
Stable id.
(item) => stringitem.id
getLabel
Card name: the article's accessible name, menu names, announcements.
(item) => stringtitle / name / label
canMove
A string blocks the move and is shown as the reason (danger outline while dragging, disabled menu item, announcement).
(e) => true | string—
beforeMove
Last check before onMove, e.g. a dialog. false cancels and the card returns.
(e) => boolean | Promise<boolean>—
summary
Under the column label (Σ, weighted Σ). Also the column's accessible description.
(items, stage) => ReactNode—
onQuickAdd
Adds a quick-add row to every column and a + button to the header. The input stays open for rapid entry; Esc closes.
(stageKey, title, lane?) => void | Promise<void>—
reorderStages
Enables Move column left / right in the column menu.
(next: Stage[]) => void—
lanes / getLaneKey
Swimlanes. Each lane × stage is its own drop target; lanes collapse.
{ key, label, icon? }[] / (item) => string—
collapsedStages / defaultCollapsedStages / onCollapsedStagesChange
Collapsed columns show a vertical label and count and still accept drops.
string[] / string[] / (keys) => voidstages with collapsed: true
cardMenu
Extra DropdownMenuItems at the end of each card's menu (Open, Archive…).
(item) => ReactNode—
virtualize
Render only visible cards in columns longer than threshold.
boolean | { estimateSize?, threshold? }false · { 96, 60 }
height
Board height; columns scroll inside it.
number | string— (36rem when virtualizing)
cardSize
Card padding; passed to renderCard as compact.
"compact" | "comfortable""comfortable"
columnWidth
15.5 / 18.5 / 22 rem.
"sm" | "md" | "lg""md"
filtered
Empty columns say "No items match" instead of "Drop items here".
boolean—
loading
Three skeleton cards per column, aria-busy on the board.
boolean—
toolbar
Slot above the board: Filter bar, Saved views, a layout switch.
ReactNode—
labels
Built-in strings. Dictionary namespace: kanban.
Partial<KanbanLabels>—
sortableLabels
Drag announcements of the underlying Sortable.
Partial<SortableLabels>—
aria-label
Name of the board region.
string"Board"

KanbanCardTitle / KanbanCardMeta / KanbanCardFooter

PropTypeDefault
…HTMLAttributes
Typographic helpers for card content. Put a link in the title for the card's main action.
h4 / div / div—

Core (React-free)

PropTypeDefault
applyKanbanMove(items, e, { getId, getStageKey, getLaneKey?, update })
Applies a move to a flat array: updates stage (and lane) and places the item at e.index in its new column.
T[]—
groupByStage(items, stages, getStageKey)
Items per stage in stage order.
Map<string, T[]>—
sumBy(items, getValue)
Exact decimal-string sum (money is never a float).
string—
weightedSum(items, stage, getValue)
Σ value × stage.probability as a decimal string.
string—
stageToneColor(tone)
Token colour for a Stage tone (semantic or chart-1…6).
string—