Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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, /sortable and /utilities come 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 for value and functions for onValueChange and getItemValue — so it lives in a client component of yours.
Origin
Ported in 2.1.0 from 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, in 3.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.

tsx
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 KanbanItemHandle inside it, or — with asHandle — by the whole card.
  • A column moves only if it has a KanbanColumnHandle or asHandle. Without either it is a drop zone that stays where it is.
  • KanbanOverlay draws 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

DocsMedium

Document the labels mechanism end to end

1 commentDS-403
ComponentsMedium

Keyboard affordance for moving kanban cards

5 commentsDS-408
ComponentsLow

Add a loading skeleton to StatCard

0 commentsDS-414

In progress2 cards

TokensUrgent

Re-map muted-foreground so AA holds on cards

6 commentsDS-401
ComponentsHigh

Pin the first DataTable column below md

9 commentsDS-404

In review2 cards

ComponentsHigh

Ship the async loading state for Combobox

3 commentsDS-402
ShellHigh

Drop the localStorage write from docs shells

6 commentsDS-415

Done1 card

ToolingHigh

Publish 0.9.1 with the StatusPill border fix

7 commentsDS-410

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 onMove set, 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

Renew the SSL certificate
Draft the Q4 roadmap
Archive 2025 invoices

Doing2/2

Migrate billing to v2
Onboard Linh Vo

Done1

Rotate the API keys

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

Close the Aurora renewal
Interview for the QA role

Next week

Quarterly access review
Plan the Hanoi offsite

Waiting on others

Legal sign-off on the DPA

Ideas

Self-serve invoice exports
A dark theme for PDFs

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.

WhenKeysResult
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

Nguyễn Thu Hà
Marcus Lindqvist
Aisha Bello

Interview

Phạm Quang Huy
Jonas Weber

Offer

Trần Bảo Châu

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 your onDragStart no longer cancels a keyboard drag — refuse drops with cancelDrop, which works for every sensor.
  • Escape puts the card back. Every move is written to onValueChange while 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 return inside forEach. Upstream turned the loop into for…of and kept the return — so one unmeasured drop zone ended the function and the arrow keys stopped moving anything. It is a continue now.
  • Announcements speak the app’s language. Upstream hard-coded English sentences; changing them meant rewriting accessibility.announcements and recomputing position and total yourself. Here four functions receive them ready-made, per key, through labels.
  • 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-label wins; the role description reads “draggable card / column” instead of dnd-kit’s “sortable”.
  • [data-disabled] means disabled. Upstream wrote data-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’s zinc-100 / zinc-900 are blocked by the package’s colour gate) with the decorative border-border, and a column or card inside KanbanOverlay is lifted with shadow-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

Foundry Labs$18,000
Granite Freight$9,500

Proposal$79,000

Aurora Logistics$48,000
Delta Shipworks$31,000

Won$22,000

Everest Analytics$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.

  • labels is Partial<KanbanLabels>, merged key by key over the provider’s table: the component’s own prop wins, then the nearest provider, then English.
  • DEFAULT_KANBAN_LABELS is the English table since 3.0.0, and the component no longer reads it — spread it when you build a table of your own. The Vietnamese one is comitorMessagesVi.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.
  • column is 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 is moved — by the time of the drop the card already sits in its new column, so dropped gives 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 over labels entirely.
  • 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.
KeyEnglishTiế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.
instructionsTo 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ỷ.
itemRoleDescriptiondraggable cardthẻ kéo thả được
columnRoleDescriptiondraggable columncột kéo thả được
itemHandleLabelDrag to move cardKéo để di chuyển thẻ
columnHandleLabelDrag to move columnKé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.

tsx
'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

PropKiểuMặc địnhMô tả
valuebắt buộcRecord<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.
flatCursorbooleanfalseA plain default cursor on handles, columns and cards instead of grab and grabbing.
labelsPartial<KanbanLabels>the kanban namespaceAnnouncements, 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 · onDragCanceldnd-kit event handlers—Called first, then the board does its own work. So onDragEnd runs before the board's last onValueChange of that drag.
sensorsSensorDescriptor[]mouse, touch, keyboardReplaces all three built-in sensors — including the keyboard one and its board-aware arrow keys, which are not exported. See Known limitations.
modifiersModifier[]—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.
accessibilityDndContext accessibility—Merged over the board's announcements and instructions; any key you pass here wins over labels entirely.
strategySortingStrategyverticalListSortingStrategyAccepted and stored, as upstream, but no part reads it: the board and every column take their strategy from orientation.
...restDndContextProps—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

PropKiểuMặc địnhMô tả
valuebắt buộcUniqueIdentifier—KanbanColumn: its key in value. KanbanItem: getItemValue of its card. An empty string throws, in both.
asHandlebooleanfalseThe 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.
disabledbooleanfalseCannot 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.
asChildbooleanfalseMerge onto your own element (Radix Slot) instead of rendering a div.
className · stylestring · 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.
...restdiv props—Forwarded. data-dragging and data-disabled are present only while true — style against them.

KanbanColumnHandle and KanbanItemHandle

PropKiểuMặc địnhMô tả
aria-labelstring'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.
disabledbooleanthe parent's disabledDisables the button and detaches the drag listeners.
asChildbooleanfalseRender your own element instead of a bare button — typically the package Button with an icon in it.
...restbutton 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

PropKiểuMặc địnhMô tả
childrenReactNode | ({ 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.
containerElement | DocumentFragment | nulldocument.bodyWhere the overlay is portalled — after mount, so the server render has no overlay at all.
dropAnimationDropAnimation | nullsource at 0.4 opacity while it settlesdnd-kit's drop animation; null turns it off.
modifiersModifier[]the board'sModifiers for the overlay only.
...restDragOverlay 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 asHandle card 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 is touch-none: asHandle cards always were, and since 3.0.1 so are KanbanItemHandle and KanbanColumnHandle — in 3.0.0 a swipe on a grip scrolled the page instead of lifting the card. Workaround: a KanbanItemHandle on any card with something to click, with room to scroll between grips. A threshold needs your own sensors with an activationConstraint — 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. Use todo, col-1 or 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 value as a column. Two cards with the same id — or a card whose id is done on a board with a done column — break the drag.
  • disabled freezes 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, return true from cancelDrop (the card goes back on drop), or ignore the change in onValueChange so it never enters.
  • strategy is ignored. It is accepted and stored, as upstream, but the sorting strategy of the board and of every column comes from orientation.

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">; with asHandle the card or column itself gets role button and tabIndex 0. Every activator carries aria-roledescription (“draggable card”, “draggable column”), aria-describedby pointing at a visually hidden copy of labels.instructions, aria-pressed while lifted, and — on handles — aria-controls pointing 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 then aria-describedby points 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 asHandle card shows the package’s focus ring, ring-2 in ring/50 with a 2px offset — the same as Checkbox and RadioGroup. A handle built from Button keeps Button’s outline.
  • KanbanBoard is a plain div with no role, so it carries no ARIA; its orientation is in data-orientation, for styling. Fixed in 3.0.1 — in 3.0.0 the board kept upstream’s aria-orientation, which axe reports as aria-allowed-attr; there, pass aria-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 of asHandle cards. And mark the overlay copy inert, so a picture of a card that is still on the board is not read twice or tabbed into.

Related