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
Backlog02
  • Design tokensLayer 1 + 2 foundation
  • Registry JSON
Doing01
  • ESLint rules11 rules enforced in CI
Done02
  • ScrambleHover
  • FuzzyText
Customize
Layout
14

Installation

npx shadcn@latest add https://craftbits.dev/r/kanban-components.json

Usage

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 on dropIndex.
  • KanbanCard. A draggable <li> chip with an optional left accent bar, label, and description. Animates layout shifts via motion's layout prop.

Understanding the component

  1. Columns own cards. Each column carries its ordered cards array, so moving a card is a splice out of the source column and a splice into the destination at a specific index. The onColumnsChange callback fires with the next full immutable columns array; onMove reports a cardId, fromColumn, toColumn, and toIndex.
  2. Native drag, native drop. Every card is draggable, every column runs the canonical dragoverpreventDefault opt-in (without it, the browser silently rejects the drop and the drop handler never fires). The drag payload travels through dataTransfer with a custom MIME so the component composes with cross-window dragging.
  3. Depth-counter hover state. A useRef counter ticks up on dragenter and down on dragleave. 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.
  4. Drop indicator via slot drag-over. Each card row owns a dragover handler 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.
  5. Keyboard fallback. Focus a card and press Space (or Enter) to pick it up; ArrowLeft / ArrowRight cycle columns; ArrowUp / ArrowDown step the insertion index inside the target column; Enter (or Space again) drops; Escape cancels.

Props

KanbanBoard

PropTypeDefaultDescription
columnsreadonly KanbanColumnData[]Controlled columns. Pair with onColumnsChange.
defaultColumnsreadonly KanbanColumnData[]Uncontrolled initial columns.
onColumnsChange(columns: KanbanColumnData[]) => voidFired with the next full columns array on every move.
onMove(event: KanbanMoveEvent) => voidFired with cardId, fromColumn, toColumn, toIndex after every move.
disabledbooleanfalseDisable every column + card.
emptyLabelReactNode'Drop cards here'Default empty-state placeholder.
columnGapstring'0.75rem'Gap between columns (CSS length).
minColumnWidthstring'14rem'Minimum width per column.
aria-labelstring'Kanban board'Accessible name for the outer region.
classNamestringMerged onto the root via cn().

KanbanColumnData

FieldTypeDescription
idstringStable identifier — matches drop targets and drag payloads.
labelReactNodeHeading rendered above the column.
cardsreadonly KanbanCardData[]Ordered list of cards in this column.
disabledbooleanDisable drops onto this column.
emptyLabelReactNodeOverride the empty-state placeholder for this single column.

KanbanCardData

FieldTypeDescription
idstringStable identifier — drives the React key and drag payload.
labelReactNodeCard body label.
colorstringOptional CSS color for the left accent bar.
descriptionReactNodeOptional secondary text under the label.
disabledbooleanDisable dragging this single card.

KanbanMoveEvent

FieldTypeDescription
cardIdstringId of the moved card.
fromColumnstringSource column id.
toColumnstringDestination column id.
toIndexnumberDestination 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 an aria-label derived from its string label, and the cards live inside <ul role="list">.
  • Every card is a focusable <li> (tabIndex=0) with aria-grabbed toggling while the keyboard pickup is active. The data-state attribute (idle / dragging / picked-up) is the same one the pointer drag uses, so styling stays unified.
  • Keyboard fallback: focus a card, press Space / Enter to pick up; ArrowLeft / ArrowRight cycle columns; ArrowUp / ArrowDown step the insertion index inside the target column; Enter / Space drops; Escape cancels.
  • Hover state survives nested card children because dragenter / dragleave are counted with a useRef depth 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 to false under prefers-reduced-motion.

Credits

  • Extracted from: terminal-dreams (src/components/frontend-design/sdp-drag-drop/ui/KanbanComponents.tsx). The original was a useDragDrop() consumer fused to a DragDropProvider context, a 7-step activeStep gate (only enabling pointer events at step 4+), pointer-capture drag with document.elementFromPoint hit-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 neighbour DragDropZone), drops the step gates and the strategy enum, owns state inline via controlled+uncontrolled props, and ships three slot-friendly pieces (KanbanBoard, KanbanColumn, KanbanCard).