Home/ Cookbook/ Lists/ Kanban board

Build a drag-and-drop kanban board

By the end of this recipe you'll have a three-column kanban (Backlog → In progress → Done) with HTML5 drag-and-drop on desktop, a long-press fallback on touch, a "Move to" menu on every card for mobile users, WIP limits with warning badges, and an add/edit modal driven by a single reducer.

Lists 3 primitives · 2 blocks · 1 pattern ~45 min React · @corelithzw/react

Overview

Columns hold cards, cards move between columns, columns warn when over their WIP limit. One reducer holds the lot.

Kanban is the queue-with-stages pattern: a record moves from "not started" through "in progress" to "done", and the visualisation makes the bottleneck obvious. For an SMB workflow — a service-request queue, a sales pipeline, a daily prep list — kanban gives ops a glanceable picture of what's stuck where. The recipe uses HTML5 drag-and-drop (no library), a column-level WIP limit, and a "Move to" menu so the board is fully usable on phones.

Dragging is for desktop power users. Always provide a "Move to" menu on each card so mobile users can move things too. Drag-and-drop alone fails accessibility and fails fingers.

Skip this recipe if your workflow has fewer than three states or the order within a column doesn't matter — a simple status filter on a data table is cheaper and clearer.

What you'll build

Three states of the same board: idle, mid-drag, and an over-limit warning.

Backlog
Restock honey
Call Jabu re: order
Audit Bulawayo float
Doing
Hire weekend cashier
Reprint price tags
Done
Refund Tendai
01 Idle — three columns, six cards
Backlog
Restock honey
Audit Bulawayo float
Doing
Hire weekend cashier
Reprint price tags
Call Jabu re: order
Done
Refund Tendai
02 Drag — column shows a drop indicator
Backlog
Restock honey
Doing · 4/3
Hire weekend cashier
Reprint price tags
Call Jabu re: order
Audit Bulawayo float
Done
Refund Tendai
03 Over limit — warn, don't block

Required pieces

A small surface — the heavy lifting is the reducer and the drag handlers.

@corelithzw/react exports used here: KanbanBoard, useKanban, RowCard, Menu, Drawer, Button, Form, Field, Input, TextArea.

Step-by-step build

01

Model the board with a reducer, not nested arrays

The board is a map of columnId → orderedCardIds[] plus a separate map of cardId → Card. That normalised shape makes "move card X to column Y at index I" a single, replayable action. Don't store the cards inside the columns — moving becomes a deep splice and the bugs that follow are diabolical.

Code-only step

order: Record<ColumnId, id[]>

MOVE is one action — works for both DnD and the "Move to" menu.

Switch to the Code tab to see the snippet.

Step 1 · Reducer@corelithzw/react
02

Wire HTML5 drag-and-drop with a single column drop zone

Each card sets draggable and writes its id to dataTransfer on dragstart. Each column listens for dragover (to allow the drop) and drop (to dispatch). Compute the drop index from the cursor's Y position against each card's mid-line — don't try to use sub-zones.

Backlog · 3
Restock honey
Call Jabu
Audit float
Doing · 2
Hire cashier
Price tags
Step 2 · Drag & drop
03

Add a "Move to" menu so phones work too

Touch devices don't fire HTML5 drag events reliably. Every card exposes a Menu with one item per destination column — the same MOVE action handles both paths. This is the difference between "looks good in a demo" and "people actually use it".

Call Jabu re: order
Assigned to Tendai · Due Fri
→ Move to In progress
→ Move to Done
Step 3 · Move-to menu
04

Show WIP limits as warnings, never blocks

Hard limits ("you can't add another card") force people to lie about state — they leave the card in the previous column. Soft limits surface the bottleneck instead. Show a count badge in amber once limit is exceeded; an aria-live region announces the warning.

In progress
4 / 3 Over WIP
Step 4 · WIP warnings

Final composition

The whole board with reducer, drag-and-drop, move-to menu, WIP limits, and the add/edit drawer.

Backlog
3
Restock honey
Tendai · Mon
Call Jabu re: order
In progress
4/3
Hire weekend cashier
Anesu
Done
1
Refund Tendai
TaskBoard.tsx@corelithzw/react

Variations

Three forks. Each is a small diff from the final composition.

Swimlanes by assignee

Group cards within each column by assignee, so you can see who's overloaded at a glance. The reducer doesn't change — only the render.

// Inside <Column>
const grouped = groupBy(
  cardIds.map((id) => cards[id]),
  (c) => c.assignee ?? 'Unassigned',
);
{Object.entries(grouped).map(([who, list]) => (
  <Lane key={who} title={who} cards={list} />
))}

Multi-board (per project)

Same board shape, multiple instances. Wrap the reducer in a board-id-keyed map; persist per id to localStorage. A picker switches between boards.

// useKanban hook
const [boards, dispatch] = useReducer(
  boardsReducer,
  loadFromStorage(),
);
return boards[currentBoardId];
// dispatch wraps inner actions in
// { boardId, ...action }

Keyboard moves (arrow keys)

Already in the final composition — but here's the slim version if you don't need the menu. Each card listens for ArrowLeft / ArrowRight and dispatches MOVE.

onKeyDown={(e) => {
  const order = ['backlog','doing','done'];
  const i = order.indexOf(currentColumn);
  if (e.key === 'ArrowLeft'  && i > 0)
    onMove(order[i - 1]);
  if (e.key === 'ArrowRight' && i < 2)
    onMove(order[i + 1]);
}}

Accessibility

Drag-and-drop is the hardest interaction to make accessible. This recipe makes the keyboard path first-class, not a fallback.

  • Every card is keyboard-focusable. tabIndex={0} + aria-label tells the screen reader the card's title and its column.
  • Explicit "Move via keyboard" path. Arrow-left / arrow-right move the focused card between columns. The aria-label says "Arrow keys to move" so users know.
  • "Move to" menu is the canonical path. Every move possible by drag is also possible from the menu — phone users, screen-reader users and keyboard users share the same affordance.
  • Columns are real role="list" regions. Each card is role="listitem". Screen readers announce "list of 3 items, in Backlog".
  • WIP warnings are announced. When a column crosses its limit, the count badge flips to aria-live="polite" and reads "4 of 3, over WIP limit".
  • Focus returns to the moved card. After a MOVE action, focus follows the card into its new column — the user doesn't lose their place.
  • Drag indicator isn't colour-only. The drop-target column gets a dashed border and a brand background — colour-blind users still see the change.