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.
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
Doing
Done
Backlog
Doing
Done
Backlog
Doing · 4/3
Done
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
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.
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.
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.
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".
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.
Final composition
The whole board with reducer, drag-and-drop, move-to menu, WIP limits, and the add/edit drawer.
Tendai · Mon
Anesu
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-labeltells 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 isrole="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.