Composites
Kanban
A board of columns and cards that people rearrange by dragging — with a mouse, a finger or the keyboard. In a Comitor app it is the task board in Tasks, the deal pipeline in CRM and the hiring pipeline in HR. The columns and the cards are your data; the component moves them, tells you where they went, and says so to a screen reader.
- Import
import { Kanban, KanbanBoard, KanbanColumn, KanbanItem } from '@comitor/ui'— the main entry, with no optional peer.@dnd-kit/core,/sortableand/utilitiescome in as ordinary dependencies of the package.- Client
- The file starts with
"use client". A Server Component can render it, but a board that moves needs state forvalueand functions foronValueChangeandgetItemValue— so it lives in a client component of yours. - Origin
- Ported in
2.1.0from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own primitives and tokens, with the fixes listed below; its strings became English, with Vietnamese built in, in3.0.0. See the changelog.
Anatomy
Seven parts, three of them optional. Kanban owns the data and the drag; KanbanBoard lays the columns out; KanbanColumn and KanbanItem register themselves under the value you give them. The board does not draw your columns — you map over value yourself, in key order.
import {
Kanban, KanbanBoard, KanbanColumn, KanbanColumnHandle,
KanbanItem, KanbanItemHandle, KanbanOverlay,
} from '@comitor/ui'
<Kanban value={columns} onValueChange={setColumns} getItemValue={(card) => card.id}>
<KanbanBoard> {/* the row — or stack — of columns */}
<KanbanColumn value="todo"> {/* one per key of value, in key order */}
<KanbanColumnHandle /> {/* optional: without it the column stays put */}
<KanbanItem value="DS-403"> {/* one per card: value = getItemValue(card) */}
<KanbanItemHandle /> {/* optional: a grip, instead of asHandle */}
</KanbanItem>
</KanbanColumn>
</KanbanBoard>
<KanbanOverlay> {/* optional: the copy that follows the pointer */}
{({ value, variant }) => <LiftedCopy id={value} kind={variant} />}
</KanbanOverlay>
</Kanban>- A card is dragged by a
KanbanItemHandleinside it, or — withasHandle— by the whole card. - A column moves only if it has a
KanbanColumnHandleorasHandle. Without either it is a drop zone that stays where it is. KanbanOverlaydraws the copy that follows the pointer, and the original stays in the list as a half-opaque placeholder. Without an overlay the card itself travels, at half opacity.
A task board
Four status columns, cards assembled from StatusPill, Badge and AvatarGroup, and counts in the headers that follow the drag — onValueChange runs while the card is still moving, not only when it lands. Each card is dragged by its grip, so the ⋯ menu beside it stays clickable, and the menu moves a card with no drag at all. The sentence under the board is computed from value after each change: that is the moment to save.
Todo3 cards
Document the labels mechanism end to end
Keyboard affordance for moving kanban cards
Add a loading skeleton to StatCard
In progress2 cards
Re-map muted-foreground so AA holds on cards
Pin the first DataTable column below md
In review2 cards
Ship the async loading state for Combobox
Drop the localStorage write from docs shells
Done1 card
Publish 0.9.1 with the StatusPill border fix
Drag a card by its grip, or move it from its ⋯ menu.
Why a grip, and not asHandle, on these cards
asHandle makes the whole card the drag activator, and the mouse and touch sensors start a drag on the first press — there is no distance or delay to cross first. dnd-kit then swallows the click that ends the press, so a button or a link inside an asHandle card never receives it, and Space on a button inside it would start a drag. Keep asHandle for cards that are only text, as in the next three demos; give every other card a KanbanItemHandle.
The value is yours
value is a plain object — column key → array of cards. Its key order is the column order and each array is the card order. The board keeps no copy: every change arrives through onValueChange and goes back in as value. Drag a card below and watch both panels — the object changes while the card is still in the air.
- During the drag, each time the card enters another column (it joins the end of that column, then moves among its cards) or passes another card in its own column.
- On drop, for a column move or a last step inside one column. With
onMoveset, the board calls it instead and leaves that reorder to you. - On cancel — Escape, a drop refused by
cancelDrop, a resized window, a hidden tab — once more if anything had moved, with the value from the moment the card was picked up.
So save on drop, not in onValueChange: it fires several times per drag, with states the user passed through on the way. And in onDragEnd the board has not applied its last reorder yet — read the final position on the next render, as the task board does. Doing below takes two cards: cancelDrop refuses a third, and the refusal is a cancel, so the card goes back to where it started. Cards can be plain strings, as here; then they are their own id and getItemValue is optional.
Backlog3
Doing2/2
Done1
value
{
"backlog": [
"Renew the SSL certificate",
"Draft the Q4 roadmap",
"Archive 2025 invoices"
],
"doing": [
"Migrate billing to v2",
"Onboard Linh Vo"
],
"done": [
"Rotate the API keys"
]
}Events, this drag
Drag a card to see them.
Moving columns
Columns are sortable too, once they have a handle — KanbanColumnHandle in the header here, or asHandle on the whole column. A column move lands on drop, as a new key order of the same object, which is also why a column key must never look like an integer (see Known limitations). The overlay’s children can be a function of { value, variant }, so one KanbanOverlay lifts either a list or a card. Drag a list by the grip beside its name.
This week
Next week
Waiting on others
Ideas
Key order: This week → Next week → Waiting on others → Ideas
Keyboard
The board listens to three sensors: mouse, touch and keyboard. With the keyboard, every drag activator is a tab stop, and the whole drag happens on it.
| When | Keys | Result |
|---|---|---|
| Idle | Tab | Move between drag activators: every grip, and every card or column with asHandle, is a tab stop. |
| Idle | SpaceEnter | Pick up the focused card or column. On focus, a screen reader has already read labels.instructions, which say exactly this. |
| Lifted | ←↑→↓ | Move to the nearest card or column in that direction. Entering another column moves the card there at once: onValueChange runs and the new position is announced. |
| Lifted | SpaceEnter | Drop. Focus goes back to the handle — even when the card changed column and its element was re-created. |
| Lifted | Tab | Also drops: Tab is one of dnd-kit’s end keys. Focus does not move. |
| Lifted | Esc | Cancel. The card goes back to where it was picked up. Resizing the window or hiding the tab cancels the same way. |
This is the part the port had to fix. dnd-kit’s keyboard sensor calls preventDefault() on the Space or Enter that starts a drag, and upstream ignored every drag event whose starting event had been default-prevented — its way of letting an app cancel from onDragStart. So a keyboard drag lifted the card, announced the drop, and never touched the data. The port measured it with real keys: Space, ArrowRight, Space — the live region said the card was dropped in the next column, and onValueChange was not called once. Try the same keys below; the line under the board prints what the live region says.
Applied
Interview
Offer
Screen reader: Tab to a candidate and press Space.
What the port guarantees
Upstream behaviour that changed on the way into the package, phrased as what you can now rely on.
- A keyboard drop changes the data. The default-prevented guard now applies to mouse and touch only. The side effect: calling
event.activatorEvent.preventDefault()in youronDragStartno longer cancels a keyboard drag — refuse drops withcancelDrop, which works for every sensor. - Escape puts the card back. Every move is written to
onValueChangewhile the card travels, so upstream’s cancel left it wherever it had got to — against its own spoken instruction to “press Escape to cancel”. The board now keeps the value from pick-up and hands it back on cancel, whatever caused the cancel. - Arrow keys do not freeze. The keyboard coordinate getter came from dnd-kit’s multiple-containers example, where skipping a drop zone without a measured rect was a
returninsideforEach. Upstream turned the loop intofor…ofand kept thereturn— so one unmeasured drop zone ended the function and the arrow keys stopped moving anything. It is acontinuenow. - Announcements speak the app’s language. Upstream hard-coded English sentences; changing them meant rewriting
accessibility.announcementsand recomputing position and total yourself. Here four functions receive them ready-made, per key, throughlabels. - Handles have a name and a role. A grip is nearly always a lone icon — a button with no name. Both handles now default to “Drag to move card / column”, set before your props so your
aria-labelwins; the role description reads “draggable card / column” instead of dnd-kit’s “sortable”. [data-disabled]means disabled. Upstream wrotedata-disabled="false"on enabled columns and cards — the attribute was present, so every[data-disabled]selector matched them. It is now absent unless the part is disabled.- Tokens, not a palette. Columns are
bg-muted(upstream’szinc-100/zinc-900are blocked by the package’s colour gate) with the decorativeborder-border, and a column or card insideKanbanOverlayis lifted withshadow-overlay.
Language
Every string Kanban produces is for assistive technology — the four drag announcements, the keyboard instructions, two role descriptions and two handle names. A screenshot never shows the wrong language; a screen reader does. They live in the kanban namespace and follow the nearest ComitorLocaleProvider — English by default, Vietnamese built in (set the language once, at the root). Switch the demo and move a deal: the announcement, the grip’s name and the instructions change with the provider; the stage names and the amounts are the app’s, formatted with useComitorLocale().locale.
Lead$27,500
Proposal$79,000
Won$22,000
Screen reader: …
Each grip: “Drag to move card”, draggable card
Then: To pick up a card or column, press Space or Enter. While dragging, use the arrow keys to move it. Press Space or Enter again to drop it in its new position, or press Escape to cancel.
labelsisPartial<KanbanLabels>, merged key by key over the provider’s table: the component’s own prop wins, then the nearest provider, then English.DEFAULT_KANBAN_LABELSis the English table since3.0.0, and the component no longer reads it — spread it when you build a table of your own. The Vietnamese one iscomitorMessagesVi.kanban.- Four keys are functions —
pickedUp,moved,dropped,cancelled— because a number and a column sit in the middle of the sentence and word order differs by language. They receive{ variant, value, position, total, column }already computed. Functions cannot cross from a Server Component into a client one, so declare them in a client component. columnis there only when the card has just entered another column, and it is that column’s key: the default English reads “…in column in-review”. In practice that ismoved— by the time of the drop the card already sits in its new column, sodroppedgives just the position. Map keys to names, as every demo on this page does; the helper below keeps the provider’s sentence and swaps the word.accessibility.announcements, dnd-kit’s own prop, is still honoured and wins overlabelsentirely.- The provider chooses strings; it does not set
lang. Vietnamese read with English rules is barely intelligible — keep<html lang>in step with the provider, as the demo does for its own region.
| Key | English | Tiếng Việt |
|---|---|---|
pickedUp(info) | Picked up card at position 2 of 5. | Đã nhấc thẻ ở vị trí 2 trên 5. |
moved(info) | Card is now at position 2 of 5 in column In review. | Thẻ đang ở vị trí 2 trên 5 trong cột Đang duyệt. |
dropped(info) | Dropped card at position 2 of 5. | Đã thả thẻ ở vị trí 2 trên 5. |
cancelled(info) | Dragging cancelled. Card returned to its original position. | Đã huỷ kéo. Thẻ trở về vị trí ban đầu. |
instructions | To pick up a card or column, press Space or Enter. While dragging, use the arrow keys to move it. Press Space or Enter again to drop it in its new position, or press Escape to cancel. | Để nhấc một thẻ hoặc một cột, nhấn Space hoặc Enter. Trong lúc kéo, dùng các phím mũi tên để di chuyển. Nhấn Space hoặc Enter lần nữa để thả vào vị trí mới, hoặc nhấn Escape để huỷ. |
itemRoleDescription | draggable card | thẻ kéo thả được |
columnRoleDescription | draggable column | cột kéo thả được |
itemHandleLabel | Drag to move card | Kéo để di chuyển thẻ |
columnHandleLabel | Drag to move column | Kéo để di chuyển cột |
Read live from comitorMessagesEn.kanban and comitorMessagesVi.kanban: a card at position 2 of 5, and for moved a column name the app supplied in place of the key.
'use client'
import { useMemo, useState } from 'react'
import { Kanban, useComitorMessages } from '@comitor/ui'
import type { KanbanAnnouncementInfo, KanbanLabels } from '@comitor/ui'
// 1. Keep the provider's sentences — in whatever language it is — and swap the
// column KEY for a name. Pass a stable names object (module constant or memo).
export function useColumnNames(names: Record<string, string>) {
const t = useComitorMessages('kanban')
return useMemo<Partial<KanbanLabels>>(() => {
const named = (info: KanbanAnnouncementInfo) =>
info.column === undefined ? info : { ...info, column: names[String(info.column)] ?? info.column }
return {
moved: (info) => t.moved(named(info)),
dropped: (info) => t.dropped(named(info)),
}
}, [t, names])
}
// 2. Or write the sentence yourself. value is the card id, so the card can be named;
// the words are then yours to translate (next-intl, in a Comitor app).
const ownSentences: Partial<KanbanLabels> = {
pickedUp: ({ value, position, total }) => `Picked up ${titleOf(value)}, ${position} of ${total}.`,
}
// Either way, labels is merged key by key over the provider's table.
export function Board() {
const [columns, setColumns] = useState(INITIAL)
const labels = useColumnNames(COLUMN_NAMES)
return <Kanban value={columns} onValueChange={setColumns} labels={labels}>…</Kanban>
}Props
KanbanBoard takes asChild and div props, sets data-orientation, and starts as flex size-full gap-4 — a grid class replaces the flex, as every demo here does. UniqueIdentifier is dnd-kit’s string | number.
Kanban
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | Record<UniqueIdentifier, T[]> | — | Column key → the cards in that column, in order. Key order is column order. Controlled only: the board keeps no copy of its own. |
onValueChange | (columns: Record<UniqueIdentifier, T[]>) => void | — | Called during the drag (each time the card enters another column or passes another card), on drop for a column move or a last in-column step, and on cancel with the value from pick-up. Without it nothing moves. |
getItemValue | (item: T) => UniqueIdentifier | — | The card's id. Required when cards are objects (TypeScript enforces it; at runtime it throws). Unique across the whole board and different from every column key. Strings and numbers are their own id. |
onMove | (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void | — | Takes over the reorder the board would apply on drop — a column move, or a last step inside one column — and the board skips onValueChange for it. Cards crossing columns still arrive through onValueChange during the drag, so this is not a complete move event. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | A row of columns or a stack of them (data-orientation on the board). Also picks the list sorting strategy of the board and of every column. |
flatCursor | boolean | false | A plain default cursor on handles, columns and cards instead of grab and grabbing. |
labels | Partial<KanbanLabels> | the kanban namespace | Announcements, keyboard instructions, role descriptions and handle names, overridden one key at a time over the nearest ComitorLocaleProvider's table. See Language. |
cancelDrop | (event) => boolean | Promise<boolean> | — | dnd-kit's own prop, passed through. Return true to refuse a drop: dnd-kit turns it into a cancel and the board restores the value from pick-up — for keyboard, mouse and touch alike. |
onDragStart · onDragOver · onDragEnd · onDragCancel | dnd-kit event handlers | — | Called first, then the board does its own work. So onDragEnd runs before the board's last onValueChange of that drag. |
sensors | SensorDescriptor[] | mouse, touch, keyboard | Replaces all three built-in sensors — including the keyboard one and its board-aware arrow keys, which are not exported. See Known limitations. |
modifiers | Modifier[] | — | dnd-kit modifiers, applied to the drag and to KanbanOverlay — e.g. from @dnd-kit/modifiers, which your app then lists as its own dependency. |
accessibility | DndContext accessibility | — | Merged over the board's announcements and instructions; any key you pass here wins over labels entirely. |
strategy | SortingStrategy | verticalListSortingStrategy | Accepted and stored, as upstream, but no part reads it: the board and every column take their strategy from orientation. |
...rest | DndContextProps | — | Everything else (autoScroll, onDragMove) goes to dnd-kit's DndContext. collisionDetection is the board's own and not in the type; id and measuring are set by the board. |
KanbanColumn and KanbanItem
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | UniqueIdentifier | — | KanbanColumn: its key in value. KanbanItem: getItemValue of its card. An empty string throws, in both. |
asHandle | boolean | false | The whole element becomes the drag activator: role button, tab stop, role description, keyboard instructions, touch-none and select-none. Only for elements with nothing clickable inside. |
disabled | boolean | false | Cannot be picked up, is dimmed (opacity-50, pointer-events-none) and stops being a drop target itself. A disabled column's cards are still drop targets — see Known limitations. |
asChild | boolean | false | Merge onto your own element (Radix Slot) instead of rendering a div. |
className · style | string · CSSProperties | — | Merged. A column starts as flex flex-col gap-2 rounded-lg border bg-muted p-2.5; an item has no box of its own. style is composed with the drag transform. |
...rest | div props | — | Forwarded. data-dragging and data-disabled are present only while true — style against them. |
KanbanColumnHandle and KanbanItemHandle
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | 'Drag to move card' / 'Drag to move column' | Your own name wins over the default from labels. Give each handle one that says which card or column — the default is the same for all of them. |
disabled | boolean | the parent's disabled | Disables the button and detaches the drag listeners. |
asChild | boolean | false | Render your own element instead of a bare button — typically the package Button with an icon in it. |
...rest | button props | — | Forwarded; type is button. A click on a handle never fires: the press has already started a drag. aria-controls points at the card or column. |
KanbanOverlay
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ({ value, variant }) => ReactNode | — | Rendered only while something is lifted. The function form receives the lifted id and whether it is a 'column' or an 'item'. A KanbanColumn or KanbanItem in here gets shadow-overlay. |
container | Element | DocumentFragment | null | document.body | Where the overlay is portalled — after mount, so the server render has no overlay at all. |
dropAnimation | DropAnimation | null | source at 0.4 opacity while it settles | dnd-kit's drop animation; null turns it off. |
modifiers | Modifier[] | the board's | Modifiers for the overlay only. |
...rest | DragOverlay props | — | adjustScale, zIndex (999), className, style, transition, wrapperElement. |
Known limitations
- A press on a handle starts a drag at once. The mouse and touch sensors have no activation distance or delay, as upstream. dnd-kit swallows the click that ends such a press — an
asHandlecard never sees a click, nor does anything inside it — and on touch a swipe that starts on a handle drags instead of scrolling. Every handle istouch-none:asHandlecards always were, and since3.0.1so areKanbanItemHandleandKanbanColumnHandle— in3.0.0a swipe on a grip scrolled the page instead of lifting the card. Workaround: aKanbanItemHandleon any card with something to click, with room to scroll between grips. A threshold needs your ownsensorswith anactivationConstraint— from@dnd-kit/core, which your app then lists itself — and it replaces the keyboard sensor too: the board’s arrow-key logic is not exported, and dnd-kit’s default keyboard sensor moves 25px per key press instead of jumping to the next card. - Column keys must not look like integers. JavaScript orders integer-like keys (
"1","2","10") numerically, ahead of every other key, whatever order you insert them in. The board moves a column by rebuilding the object in the new key order, so with such keys the move is silently undone. Usetodo,col-1or a UUID. - Ids are board-wide. dnd-kit keeps one registry per board, and the board treats any id that is also a key of
valueas a column. Two cards with the same id — or a card whose id isdoneon a board with adonecolumn — break the drag. disabledfreezes a column, not its cards. A disabled column cannot be picked up and is no longer a drop target itself, but its cards still are: drag over one of them and the card goes in. To make a column refuse cards, returntruefromcancelDrop(the card goes back on drop), or ignore the change inonValueChangeso it never enters.strategyis ignored. It is accepted and stored, as upstream, but the sorting strategy of the board and of every column comes fromorientation.
Accessibility
- Keyboard: Tab reaches every drag activator; Space or Enter picks up; the arrow keys move to the nearest card or column; Space, Enter or Tab drops; Escape cancels and restores. The full map is under Keyboard.
- Handles render a real
<button type="button">; withasHandlethe card or column itself getsrolebutton andtabIndex0. Every activator carriesaria-roledescription(“draggable card”, “draggable column”),aria-describedbypointing at a visually hidden copy oflabels.instructions,aria-pressedwhile lifted, and — on handles —aria-controlspointing at the card or column. - Announcements go through dnd-kit’s live region (
role="status",aria-live="assertive",aria-atomic): at pick-up, at every new position, at drop and at cancel. The region and the instructions mount after hydration; until thenaria-describedbypoints at nothing. - After a keyboard drop, dnd-kit puts focus back on the handle — also when the card changed column and React re-created its element.
- An
asHandlecard shows the package’s focus ring,ring-2inring/50with a 2px offset — the same asCheckboxandRadioGroup. A handle built fromButtonkeeps Button’s outline. KanbanBoardis a plain div with no role, so it carries no ARIA; its orientation is indata-orientation, for styling. Fixed in3.0.1— in3.0.0the board kept upstream’saria-orientation, which axe reports asaria-allowed-attr; there, passaria-orientation={undefined}to drop it.- What the app still has to do. Name every handle after its card or column — the default name is the same for all of them. Put names into the announcements through
labels— by default they give a position and a column key, never which card. Offer a way to move a card without dragging (WCAG 2.5.7, Dragging Movements): a Move to menu like the task board’s, or a status field in the card’s detail view. Keep interactive content out ofasHandlecards. And mark the overlay copyinert, so a picture of a card that is still on the board is not read twice or tabbed into.
Related
Tasks pattern
The board in a whole screen: filters above it, a detail view behind each card, and the same StatusConfig tables.
CRM pattern
Deals by stage — a pipeline is a board whose columns carry money totals.
HR pattern
Hiring and onboarding pipelines, where the columns are stages a person moves through.
Status Pill
The column headers and the priority dots on the task cards — the app's StatusConfig, painted with tokens.
Timeline
What happened to a card over time, once it opens: moves, comments, reviews.
Badge Overflow
Labels on a card that is narrower than its labels — they fold into “+N”.