Kanban Components
A compound family of three Kanban primitives — KanbanBoard, KanbanColumn, and KanbanCard — for lessons that explain HTML5 drag-and-drop, depth-counter hover state, drop-index resolution, or board-state immutability. Each column owns an ordered cards array, and the board manages drop-target hover, the drop indicator, and the move dispatch.
Preview
- Design tokensLayer 1 + 2 foundation
- Registry JSON
- ESLint rules11 rules enforced in CI
- ScrambleHover
- FuzzyText
Customize
Layout
14
Installation
npx shadcn@latest add https://craftbits.dev/r/kanban-components.jsonUsage
import { useState } from "react";
import { KanbanBoard, type KanbanColumnData } from "@craft-bits/core";
const [columns, setColumns] = useState<KanbanColumnData[]>([
{ id: "backlog", label: "Backlog", cards: [{ id: "tokens", label: "Design tokens" }] },
{ id: "doing", label: "Doing", cards: [{ id: "lint", label: "ESLint rules" }] },
{ id: "done", label: "Done", cards: [{ id: "scramble", label: "ScrambleHover" }] },
]);
<KanbanBoard
columns={columns}
onColumnsChange={setColumns}
onMove={(event) => console.log(event)}
aria-label="Task board"
/>Compose the parts directly when you need a custom board layout:
import { KanbanColumn, KanbanCard } from "@craft-bits/core";
<KanbanColumn
column={{
id: "doing",
label: "Doing",
cards: [{ id: "x", label: "Important", color: "#facc15" }],
}}
/>Anatomy
- KanbanBoard. A
<div role="region">laying out columns in an auto-fit grid. Owns the drag-state machine, hover-depth counter, drop-indicator index, and the move dispatch. - KanbanColumn. A headed
<section>with a label, a count badge, and an ordered<ul>of cards. Renders the drop indicator between cards based ondropIndex. - KanbanCard. A draggable
<li>chip with an optional left accent bar, label, and description. Animates layout shifts viamotion'slayoutprop.
Understanding the component
- Columns own cards. Each column carries its ordered
cardsarray, so moving a card is a splice out of the source column and a splice into the destination at a specific index. TheonColumnsChangecallback fires with the next full immutable columns array;onMovereports acardId,fromColumn,toColumn, andtoIndex. - Native drag, native drop. Every card is
draggable, every column runs the canonicaldragover→preventDefaultopt-in (without it, the browser silently rejects the drop and the drop handler never fires). The drag payload travels throughdataTransferwith a custom MIME so the component composes with cross-window dragging. - Depth-counter hover state. A
useRefcounter ticks up ondragenterand down ondragleave. The column is hovered when the counter is positive, which keeps the dashed border stable when the cursor crosses into a nested card child — boolean hover would flicker false then true on every traversal. - Drop indicator via slot drag-over. Each card row owns a
dragoverhandler that decides — based on whether the cursor sits in the upper or lower half — which insertion index it represents. The board renders a 2px accent rule at that index instead of moving the chip while you hover. - Keyboard fallback. Focus a card and press
Space(orEnter) to pick it up;ArrowLeft/ArrowRightcycle columns;ArrowUp/ArrowDownstep the insertion index inside the target column;Enter(orSpaceagain) drops;Escapecancels.
Props
KanbanBoard
| Prop | Type | Default | Description |
|---|---|---|---|
columns | readonly KanbanColumnData[] | — | Controlled columns. Pair with onColumnsChange. |
defaultColumns | readonly KanbanColumnData[] | — | Uncontrolled initial columns. |
onColumnsChange | (columns: KanbanColumnData[]) => void | — | Fired with the next full columns array on every move. |
onMove | (event: KanbanMoveEvent) => void | — | Fired with cardId, fromColumn, toColumn, toIndex after every move. |
disabled | boolean | false | Disable every column + card. |
emptyLabel | ReactNode | 'Drop cards here' | Default empty-state placeholder. |
columnGap | string | '0.75rem' | Gap between columns (CSS length). |
minColumnWidth | string | '14rem' | Minimum width per column. |
aria-label | string | 'Kanban board' | Accessible name for the outer region. |
className | string | — | Merged onto the root via cn(). |
KanbanColumnData
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier — matches drop targets and drag payloads. |
label | ReactNode | Heading rendered above the column. |
cards | readonly KanbanCardData[] | Ordered list of cards in this column. |
disabled | boolean | Disable drops onto this column. |
emptyLabel | ReactNode | Override the empty-state placeholder for this single column. |
KanbanCardData
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier — drives the React key and drag payload. |
label | ReactNode | Card body label. |
color | string | Optional CSS color for the left accent bar. |
description | ReactNode | Optional secondary text under the label. |
disabled | boolean | Disable dragging this single card. |
KanbanMoveEvent
| Field | Type | Description |
|---|---|---|
cardId | string | Id of the moved card. |
fromColumn | string | Source column id. |
toColumn | string | Destination column id. |
toIndex | number | Destination index inside the destination column's cards. |
Accessibility
- The board root is a
<div role="region" aria-label="Kanban board">. Each column is a<section>with anaria-labelderived from its stringlabel, and the cards live inside<ul role="list">. - Every card is a focusable
<li>(tabIndex=0) witharia-grabbedtoggling while the keyboard pickup is active. Thedata-stateattribute (idle/dragging/picked-up) is the same one the pointer drag uses, so styling stays unified. - Keyboard fallback: focus a card, press
Space/Enterto pick up;ArrowLeft/ArrowRightcycle columns;ArrowUp/ArrowDownstep the insertion index inside the target column;Enter/Spacedrops;Escapecancels. - Hover state survives nested card children because
dragenter/dragleaveare counted with auseRefdepth counter, not a boolean — no flicker when the cursor crosses into label text or icons. - Disabled cards stay focusable but skip the drag start; disabled columns reject drops while keeping their cards visible and draggable out.
- Layout shifts use motion's
layout="position"prop, which short-circuits tofalseunderprefers-reduced-motion.
Credits
- Extracted from:
terminal-dreams(src/components/frontend-design/sdp-drag-drop/ui/KanbanComponents.tsx). The original was auseDragDrop()consumer fused to aDragDropProvidercontext, a 7-stepactiveStepgate (only enabling pointer events at step 4+), pointer-capture drag withdocument.elementFromPointhit-testing, a custom<DragPreview>floating chip, per-strategy hit-test modes, and CSS-module styling. craft-bits swaps the pointer-capture model for canonical HTML5 drag-and-drop (matching the neighbourDragDropZone), drops the step gates and the strategy enum, owns state inline via controlled+uncontrolled props, and ships three slot-friendly pieces (KanbanBoard,KanbanColumn,KanbanCard).