Skip to content
Dethink Components

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 components

    Accessibility hooks and overlay utilities used by the implementation.

  • React Aria Components (react-aria-components) · via shared Dethink components

    Accessible 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.

Usage
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.

Backlog

3 cards
  • DT-142Web

    Empty state for saved filters

    2 ptsMC
  • DT-155API

    Rate-limit the export endpoint

    3 ptsRP
  • DT-158Design

    Audit icon weights in dark mode

    1 ptsIL

Estimate6 pts

In progress

3 cards

Hard limit: finish before you start.

  • DT-131Web

    Keyboard moves for the board

    5 ptsMC
  • DT-137API

    Optimistic writes with rollback

    3 ptsRP
  • DT-139Design

    Lift and tilt drag motion

    2 ptsIL

Estimate10 pts

In review

3 cards
  • DT-120Infra

    Swimlane grouping query

    3 ptsRP
  • DT-118Web

    WIP meter in column headers

    2 ptsMC
  • DT-121Design

    Drop indicator spec

    1 ptsIL

Estimate6 pts

Done

1 card
  • DT-101Web

    Board scaffolding

    3 ptsMC

Shipped3 pts

Show sourceexamples/kanban-board/sprint-board.tsx
examples/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.

Ideas

2 cards
  • Release notes

    Summarise what changed this week.

  • Onboarding tips

    Three shortcuts every new user needs.

Drafting

1 card
  • Pricing FAQ

    Answer the top five billing questions.

Published

0 cards
Show sourceexamples/kanban-board/handle.tsx
examples/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>
      )}
    />
  );
}
KanbanBoard props
PropWhat it doesDefault
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) => voidCalled when a move is committed, or when a pending async move resolves.Not set
onMove(move, { item, items, previousItems }) => void | PromisePersist 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) => ReactNodeCard 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 | stringRefuse a move. Return a string to explain why; it is shown and announced.Not set
getItemLabel(item) => stringPlain-text name for announcements and the Move menu.item.title ?? item.id
getErrorMessage(error, item) => stringMessage 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"
dragTiltnumberMaximum tilt in degrees, driven by horizontal drag speed. 0 turns it off.6
columnWidthstringCSS width of an expanded column."18rem"
collapsibleColumnsbooleanShows 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, …) => ReactNodeExtra slots for column totals and empty cells.Not set
showMoveMenubooleanThe per-card Move menu. It is the single-pointer alternative to dragging, so keep it on unless you provide another one.true
disabledbooleanRead-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"
KanbanColumn
PropWhat it doesDefault
id / titlestringStable id and visible name.Not set
tone"neutral" | "primary" | "info" | "success" | "warning" | "destructive"Accent line and status dot."neutral"
icon / descriptionReactNode / stringOptional header decoration and subtitle.Not set
wipLimit{ max, min?, mode?: "soft" | "hard" } | numberWork-in-progress limit shown as a meter. A number is soft. Hard limits refuse drops that would pass max.Not set
dropDisabledbooleanRefuses every incoming card.false
renderCard state
PropWhat it doesDefault
location / column / laneKanbanLocation / KanbanColumn / KanbanLaneWhere the card is rendered.Not set
isDragging / isOverlaybooleanTrue for the card being moved, and for the floating copy that follows the pointer.Not set
isPending / errorboolean / string | nullAsync save state.Not set
dragHandlePropsobject | undefinedSpread 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.