ComponentsKanbanBoard
KanbanBoard
Move work between stages. Cards lift, tilt with your hand, and spring into place. Every drag has a keyboard and menu equivalent, WIP limits push back, and failed saves spring home.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- Lucide
(lucide-react)React SVG icons used in component controls and status indicators.
- Motion for React
(motion)Animations, transitions, and reduced-motion handling.
- React DOM
(react-dom)Portals for rendering overlay content outside the normal component tree.
- React Aria
(react-aria)· via shared Dethink componentsAccessibility hooks and overlay utilities used by the implementation.
- React Aria Components
(react-aria-components)· via shared Dethink componentsAccessible interaction primitives, including keyboard behavior, focus handling, and ARIA semantics.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import { KanbanBoard } from "@dethink/components";Controlled items, async saves, and cards you render yourself.
Sprint board
In progress has a hard limit of three, so it refuses a fourth card. In review has a soft limit and warns when it's over. Group by assignee for swimlanes. Turn on failed saves to see a card spring back with a retry.
Drag a card, or focus one and press Space.
Press Space or Enter to pick up this card. Use the arrow keys to move it, Space or Enter to drop it, and Escape to cancel.
Show sourceexamples/kanban-board/sprint-board.tsx
"use client";
import {
Button,
KanbanBoard,
Switch,
type KanbanColumn,
type KanbanLane,
type KanbanMove,
} from "@dethink/components";
import {
CircleCheck,
CircleDashed,
CircleDot,
Eye,
Flame,
SignalHigh,
SignalLow,
SignalMedium,
} from "lucide-react";
import { useId, useState } from "react";
type Priority = "urgent" | "high" | "medium" | "low";
interface Issue {
id: string;
columnId: string;
laneId?: string;
key: string;
title: string;
tag: "Web" | "API" | "Design" | "Infra";
priority: Priority;
points: number;
assignee: string;
}
const columns: KanbanColumn[] = [
{
id: "backlog",
title: "Backlog",
tone: "neutral",
icon: <CircleDashed />,
},
{
id: "progress",
title: "In progress",
description: "Hard limit: finish before you start.",
tone: "primary",
icon: <CircleDot />,
wipLimit: { max: 3, mode: "hard" },
},
{
id: "review",
title: "In review",
tone: "warning",
icon: <Eye />,
wipLimit: 2,
},
{
id: "done",
title: "Done",
tone: "success",
icon: <CircleCheck />,
},
];
const people: KanbanLane[] = [
{ id: "maya", title: "Maya Chen", description: "Frontend" },
{ id: "ravi", title: "Ravi Patel", description: "Platform" },
{ id: "ines", title: "Inès Laurent", description: "Design" },
];
const initialIssues: Issue[] = [
{
id: "i1",
columnId: "backlog",
laneId: "maya",
key: "DT-142",
title: "Empty state for saved filters",
tag: "Web",
priority: "low",
points: 2,
assignee: "maya",
},
{
id: "i2",
columnId: "backlog",
laneId: "ravi",
key: "DT-155",
title: "Rate-limit the export endpoint",
tag: "API",
priority: "high",
points: 3,
assignee: "ravi",
},
{
id: "i3",
columnId: "backlog",
laneId: "ines",
key: "DT-158",
title: "Audit icon weights in dark mode",
tag: "Design",
priority: "medium",
points: 1,
assignee: "ines",
},
{
id: "i4",
columnId: "progress",
laneId: "maya",
key: "DT-131",
title: "Keyboard moves for the board",
tag: "Web",
priority: "urgent",
points: 5,
assignee: "maya",
},
{
id: "i5",
columnId: "progress",
laneId: "ravi",
key: "DT-137",
title: "Optimistic writes with rollback",
tag: "API",
priority: "high",
points: 3,
assignee: "ravi",
},
{
id: "i6",
columnId: "progress",
laneId: "ines",
key: "DT-139",
title: "Lift and tilt drag motion",
tag: "Design",
priority: "medium",
points: 2,
assignee: "ines",
},
{
id: "i7",
columnId: "review",
laneId: "ravi",
key: "DT-120",
title: "Swimlane grouping query",
tag: "Infra",
priority: "medium",
points: 3,
assignee: "ravi",
},
{
id: "i8",
columnId: "review",
laneId: "maya",
key: "DT-118",
title: "WIP meter in column headers",
tag: "Web",
priority: "low",
points: 2,
assignee: "maya",
},
{
id: "i9",
columnId: "review",
laneId: "ines",
key: "DT-121",
title: "Drop indicator spec",
tag: "Design",
priority: "low",
points: 1,
assignee: "ines",
},
{
id: "i10",
columnId: "done",
laneId: "maya",
key: "DT-101",
title: "Board scaffolding",
tag: "Web",
priority: "medium",
points: 3,
assignee: "maya",
},
];
const tagClasses: Record<Issue["tag"], string> = {
Web: "bg-info/12 text-info",
API: "bg-primary/10 text-primary",
Design: "bg-warning/15 text-warning",
Infra: "bg-success/12 text-success",
};
const priorityIcon: Record<Priority, typeof Flame> = {
urgent: Flame,
high: SignalHigh,
medium: SignalMedium,
low: SignalLow,
};
const initials = (id: string) =>
(people.find((person) => person.id === id)?.title ?? id)
.split(" ")
.map((part) => part[0])
.join("");
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export function KanbanSprintBoard() {
const [issues, setIssues] = useState(initialIssues);
const [grouped, setGrouped] = useState(false);
const [failSaves, setFailSaves] = useState(false);
const [log, setLog] = useState<string[]>([]);
const failId = useId();
const handleMove = async (move: KanbanMove) => {
const issue = issues.find((candidate) => candidate.id === move.itemId);
const shouldFail = failSaves;
await wait(700);
if (shouldFail) {
setLog((entries) =>
[`${issue?.key} · save failed`, ...entries].slice(0, 4),
);
throw new Error("offline");
}
setLog((entries) =>
[
`${issue?.key} → ${columns.find((c) => c.id === move.to.columnId)?.title}`,
...entries,
].slice(0, 4),
);
};
return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div
role="group"
aria-label="Group by"
className="bg-muted inline-flex rounded-lg p-0.5"
>
{[
{ value: false, label: "Status" },
{ value: true, label: "Assignee" },
].map((option) => (
<Button
key={option.label}
size="sm"
variant={grouped === option.value ? "outline" : "ghost"}
aria-pressed={grouped === option.value}
className="h-7 border-transparent px-3"
onClick={() => setGrouped(option.value)}
>
{option.label}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<Switch
id={failId}
checked={failSaves}
onCheckedChange={setFailSaves}
controlSize="sm"
/>
<label htmlFor={failId} className="text-muted-foreground text-sm">
Simulate failed saves
</label>
</div>
<p
className="text-muted-foreground ms-auto truncate font-mono text-xs"
aria-live="off"
>
{log[0] ?? "Drag a card, or focus one and press Space."}
</p>
</div>
<KanbanBoard<Issue>
label="Sprint 24"
className={grouped ? "h-[40rem]" : "h-[34rem]"}
columns={columns}
lanes={grouped ? people : undefined}
items={issues}
onItemsChange={setIssues}
onMove={handleMove}
getItemLabel={(issue) => `${issue.key} ${issue.title}`}
getErrorMessage={() => "Couldn't reach the server."}
columnWidth="17.5rem"
renderColumnFooter={(column, items) => (
<p className="text-muted-foreground flex items-center justify-between px-1 text-xs">
<span>{column.id === "done" ? "Shipped" : "Estimate"}</span>
<span className="tabular-nums">
{items.reduce((total, issue) => total + issue.points, 0)} pts
</span>
</p>
)}
renderCard={(issue, state) => {
const Priority = priorityIcon[issue.priority];
return (
<div
className={
state.isPending ? "opacity-70 transition-opacity" : undefined
}
>
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-xs">
<span className="font-mono">{issue.key}</span>
<span
className={`rounded px-1.5 py-px text-[0.6875rem] font-medium ${tagClasses[issue.tag]}`}
>
{issue.tag}
</span>
</div>
<p className="text-foreground text-sm leading-snug font-medium">
{issue.title}
</p>
<div className="text-muted-foreground mt-3 flex items-center gap-2 text-xs">
<Priority
aria-label={`${issue.priority} priority`}
className={
issue.priority === "urgent"
? "text-destructive size-3.5"
: "size-3.5"
}
/>
<span className="tabular-nums">{issue.points} pts</span>
<span
aria-label={
people.find((person) => person.id === issue.assignee)?.title
}
className="bg-foreground/8 text-foreground ms-auto inline-flex size-6 items-center justify-center rounded-full text-[0.625rem] font-semibold"
>
{initials(issue.assignee)}
</span>
</div>
</div>
);
}}
/>
</div>
);
}Drag handle
With dragHandle="handle", only the grip starts a pointer drag, so card text stays selectable. Keyboard and menu moves still work on the whole card.
Press Space or Enter to pick up this card. Use the arrow keys to move it, Space or Enter to drop it, and Escape to cancel.
Show sourceexamples/kanban-board/handle.tsx
"use client";
import { KanbanBoard, type KanbanColumn } from "@dethink/components";
import { GripVertical } from "lucide-react";
interface Note {
id: string;
columnId: string;
title: string;
body: string;
}
const columns: KanbanColumn[] = [
{ id: "ideas", title: "Ideas", tone: "info" },
{ id: "drafting", title: "Drafting", tone: "warning", wipLimit: 2 },
{ id: "published", title: "Published", tone: "success" },
];
const notes: Note[] = [
{
id: "n1",
columnId: "ideas",
title: "Release notes",
body: "Summarise what changed this week.",
},
{
id: "n2",
columnId: "ideas",
title: "Onboarding tips",
body: "Three shortcuts every new user needs.",
},
{
id: "n3",
columnId: "drafting",
title: "Pricing FAQ",
body: "Answer the top five billing questions.",
},
];
export function KanbanHandle() {
return (
<KanbanBoard<Note>
label="Content pipeline"
className="h-[22rem]"
columns={columns}
defaultItems={notes}
dragHandle="handle"
dragTilt={3}
columnWidth="15rem"
renderCard={(note, { dragHandleProps }) => (
<div className="flex gap-2">
<span
{...dragHandleProps}
aria-hidden="true"
className="text-muted-foreground hover:text-foreground -ms-1 mt-0.5 rounded"
>
<GripVertical className="size-4" />
</span>
<div className="min-w-0">
<p className="text-sm font-medium">{note.title}</p>
<p className="text-muted-foreground mt-1 text-xs select-text">
{note.body}
</p>
</div>
</div>
)}
/>
);
}| Prop | What it does | Default |
|---|---|---|
columnsKanbanColumn[] | Columns in display order. | Not set |
items / defaultItemsT extends KanbanItem[] | Ordered cards, as { id, columnId, laneId? } plus your own fields. Array order sets the order inside each cell. | Not set |
onItemsChange(items, move) => void | Called when a move is committed, or when a pending async move resolves. | Not set |
onMove(move, { item, items, previousItems }) => void | Promise | Persist a move. Return a Promise to save optimistically: the card is marked as saving, and springs back with an error and Retry if the Promise rejects. | Not set |
renderCard(item, state) => ReactNode | Card content. The board provides the surface, focus, and Move menu. | Not set |
lanesKanbanLane[] | Swimlanes. Cards without a known laneId go in the first lane. Lanes can collapse. | Not set |
canMove(move, item) => boolean | string | Refuse a move. Return a string to explain why; it is shown and announced. | Not set |
getItemLabel(item) => string | Plain-text name for announcements and the Move menu. | item.title ?? item.id |
getErrorMessage(error, item) => string | Message shown on a card whose save failed. | "Couldn't save this move." |
dragHandle"card" | "handle" | Where pointer drags start. With handle, spread state.dragHandleProps on your grip. | "card" |
dragTiltnumber | Maximum tilt in degrees, driven by horizontal drag speed. 0 turns it off. | 6 |
columnWidthstring | CSS width of an expanded column. | "18rem" |
collapsibleColumnsboolean | Shows a collapse button that shrinks a column to a rail showing its count. | true |
collapsedColumns / collapsedLanesstring[] | Controlled collapsed ids, with default… and on…Change counterparts. | Not set |
renderColumnFooter / renderEmptyColumn(column, …) => ReactNode | Extra slots for column totals and empty cells. | Not set |
showMoveMenuboolean | The per-card Move menu. It is the single-pointer alternative to dragging, so keep it on unless you provide another one. | true |
disabledboolean | Read-only board: no drags, keyboard moves, or menus. | false |
label / labelsstring / Partial<KanbanBoardLabels> | Accessible name and every string the board shows or announces. | "Kanban board" |
| Prop | What it does | Default |
|---|---|---|
id / titlestring | Stable id and visible name. | Not set |
tone"neutral" | "primary" | "info" | "success" | "warning" | "destructive" | Accent line and status dot. | "neutral" |
icon / descriptionReactNode / string | Optional header decoration and subtitle. | Not set |
wipLimit{ max, min?, mode?: "soft" | "hard" } | number | Work-in-progress limit shown as a meter. A number is soft. Hard limits refuse drops that would pass max. | Not set |
dropDisabledboolean | Refuses every incoming card. | false |
| Prop | What it does | Default |
|---|---|---|
location / column / laneKanbanLocation / KanbanColumn / KanbanLane | Where the card is rendered. | Not set |
isDragging / isOverlayboolean | True for the card being moved, and for the floating copy that follows the pointer. | Not set |
isPending / errorboolean / string | null | Async save state. | Not set |
dragHandlePropsobject | undefined | Spread on your grip when dragHandle="handle". | Not set |
Items are one ordered array. Each item has an id, a columnId, and an optional laneId, and array order sets the order inside each cell. Every move is reported as a KanbanMove with a from and to location. You can use applyKanbanMove to apply one to your own store.
If onMove returns a Promise, the board shows the card in its new place and marks it as saving until the Promise settles. When it resolves, the board calls onItemsChange. When it rejects, the card springs back to its last saved place, shows the message from getErrorMessage, and offers Retry. Re-applying a move is idempotent, so a store that also updates optimistically stays consistent.
A numeric wipLimit is soft: the header meter turns red and says the column is over its limit, but drops still go through. A hard limit refuses cards that would push the column past its max, and explains why in the header, the move menu, and the live region. Reordering inside a full column always works. Use canMove for your own rules, and return a string to explain a refusal.
The board is a labelled region, each column a group, and each cell a list. Cards share one tab stop. Use the arrow keys to move between cards, and Home or End to jump to the ends of a column.
Press Space or Enter to pick up a card. The arrow keys then move it within the column, into the next swimlane at the column's edges, or across columns, skipping columns that refuse it. Space or Enter drops the card, and Escape puts it back. Every step is announced with the column name and a one-based position.
Each card also has a Move menu with up, down, top, bottom, column, and lane destinations. This gives single-pointer and switch users the same result without dragging, which WCAG 2.2 SC 2.5.7 requires. Pointer drags start after 5px with a mouse, or after a 180ms press on touch, so the page still scrolls. Reduced motion removes the tilt, lift, and springs, but keeps the announcements.