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.
Qualified
2Website relaunch
Loyalty app, phase 1
Discovery
2POS rollout, 14 stores
Booking engine
Proposal
33 of 4Annual SEO retainer
Partner portal
Menu QR + ordering
Negotiation
1Data migration
Closed won
1Brand refresh
Installation
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
2Why the four-day week stuck
Inside the night market kitchens
In review
4Over the work-in-progress limit of 3A beginner's guide to rent caps
The city's last bookbinders
Cycling lanes, one year on
Weekend: what's on
Scheduled
1Election explainer: the new districts
Published
1Harbour clean-up, in pictures
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
2Phone screen
1Interview
22 of 2Offer
1Hired
1Aylin Yıldız
Jakub Nowak
Grace Liu
Lucía Herrera
Kenji Watanabe
Samuel Adeyemi
Ines Moreau
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
1Harborview Hotels — guest app
Negotiation
2Pinecrest Schools — LMS pilot
Atlas Freight — tracking portal
Won
0Lost
0With 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
2Card reader disconnects after update
Invoice PDF shows wrong VAT number
Open
22 of 5Can't invite a second admin
Export to CSV times out on 50k rows
Waiting on customer
2Wrong opening hours on the booking page
Refund stuck in processing
Solved
1Dark mode logo is invisible
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,500Picking
300Packed
150Shipped
50Order #50160
Order #50161
Order #50162
Order #50163
Order #50164
Order #50165
Order #50166
Order #50167
Order #50168
Order #50169
Order #50170
Order #50171
Order #50172
Order #50173
Order #50174
Order #50175
Order #50176
Order #50177
Order #50178
Order #50179
Order #50180
Order #50181
Order #50182
Order #50183
Order #50184
Order #50185
Order #50186
Order #50187
Order #50188
Order #50189
Order #50190
Order #50191
Order #50192
Order #50193
Order #50194
Order #50195
Order #50196
Order #50197
Order #50198
Order #50199
Order #50200
Order #50201
Order #50202
Order #50203
Order #50204
Order #50205
Order #50206
Order #50207
Order #50208
Order #50209
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
1Write the Q4 onboarding email
In progress
3Over the work-in-progress limit of 2Audit role permissions
Fix broken links on /pricing
Translate the help centre to Turkish
Done
0API
Kanban
| Prop | Type | Default |
|---|---|---|
stagesColumns in order. Stage: { key, label, tone?, category?, probability?, wipLimit?, collapsed? }. | Stage[] | — |
itemsCards. Column order follows the order of items. | T[] | — |
getStageKeyStage key of an item. | (item) => string | — |
renderCardCard content inside the kit's card frame. Also renders the dragged clone (overlay: true). | (item, { isDragging, compact, overlay, saving, stage }) => ReactNode | — |
onMoveOnce 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> | — |
getIdStable id. | (item) => string | item.id |
getLabelCard name: the article's accessible name, menu names, announcements. | (item) => string | title / name / label |
canMoveA string blocks the move and is shown as the reason (danger outline while dragging, disabled menu item, announcement). | (e) => true | string | — |
beforeMoveLast check before onMove, e.g. a dialog. false cancels and the card returns. | (e) => boolean | Promise<boolean> | — |
summaryUnder the column label (Σ, weighted Σ). Also the column's accessible description. | (items, stage) => ReactNode | — |
onQuickAddAdds 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> | — |
reorderStagesEnables Move column left / right in the column menu. | (next: Stage[]) => void | — |
lanes / getLaneKeySwimlanes. Each lane × stage is its own drop target; lanes collapse. | { key, label, icon? }[] / (item) => string | — |
collapsedStages / defaultCollapsedStages / onCollapsedStagesChangeCollapsed columns show a vertical label and count and still accept drops. | string[] / string[] / (keys) => void | stages with collapsed: true |
cardMenuExtra DropdownMenuItems at the end of each card's menu (Open, Archive…). | (item) => ReactNode | — |
virtualizeRender only visible cards in columns longer than threshold. | boolean | { estimateSize?, threshold? } | false · { 96, 60 } |
heightBoard height; columns scroll inside it. | number | string | — (36rem when virtualizing) |
cardSizeCard padding; passed to renderCard as compact. | "compact" | "comfortable" | "comfortable" |
columnWidth15.5 / 18.5 / 22 rem. | "sm" | "md" | "lg" | "md" |
filteredEmpty columns say "No items match" instead of "Drop items here". | boolean | — |
loadingThree skeleton cards per column, aria-busy on the board. | boolean | — |
toolbarSlot above the board: Filter bar, Saved views, a layout switch. | ReactNode | — |
labelsBuilt-in strings. Dictionary namespace: kanban. | Partial<KanbanLabels> | — |
sortableLabelsDrag announcements of the underlying Sortable. | Partial<SortableLabels> | — |
aria-labelName of the board region. | string | "Board" |
KanbanCardTitle / KanbanCardMeta / KanbanCardFooter
| Prop | Type | Default |
|---|---|---|
…HTMLAttributesTypographic helpers for card content. Put a link in the title for the card's main action. | h4 / div / div | — |
Core (React-free)
| Prop | Type | Default |
|---|---|---|
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 | — |