Frontend System Design: Kanban Board (Trello-Style)

interviewSeptember 07, 2026ยท 9 min read

"Design a kanban board" sounds like a drag-and-drop exercise. Columns, cards, drag a card, done โ€” a day of work with a library. That's the tutorial version. The production version โ€” the one Trello actually ships โ€” is a distributed state-synchronisation problem wearing a board's clothing: two people dragging the same card on flaky connections, ordering that must survive without renumbering the world, drags that feel instant even when the network takes 400ms, and keyboard users who still need to move cards.

This article walks through the full design using the RADIO framework: Requirements, Architecture, Data Model, Interface, Optimisations.

R โ€” Requirements

Clarify scope first โ€” the interviewer is testing whether you see the hard parts unprompted.

Functional:

  • Multiple lists (columns) with cards; lists themselves can be reordered
  • Drag a card within a list (reorder) and across lists (move + reorder)
  • Create, edit, archive cards; a card can carry labels, a due date, and an assignee
  • Multiple users can move cards simultaneously โ€” changes appear live for everyone
  • Undo a move (Ctrl+Z), and drag a card to an "archive" drop zone

Non-functional:

  • The card under the cursor must track the cursor at 60fps โ€” zero perceptible lag between pointer and card
  • Every move is optimistic: UI updates the same frame the pointer releases
  • Survives offline: moves made on a plane replay when the network returns
  • Keyboard-accessible drag: a screen-reader user can move a card between lists
  • No renumbering storms: moving one card must not rewrite the position of every other card

Good clarifying questions: How many cards per list (50 or 5,000)? Do two users ever drag the same card at once? Is order within a list a strong invariant (compliance boards) or best-effort? Real-time via WebSocket or poll? These questions signal senior thinking before any code.

A โ€” Architecture

Four component types and one normalized state container:

BoardProvider        (state + server sync, no UI)
โ”œโ”€โ”€ List             (drop target, scroll container, its own card order)
โ”‚   โ””โ”€โ”€ Card         (drag source โ€” cheap, dumb, position: absolute during drag)
โ”œโ”€โ”€ DragLayer        (the floating card that follows the pointer)
โ””โ”€โ”€ BoardBackground  (empty-state drop zones, auto-scroll edges)

BoardProvider owns a normalized cache:

state = {
  lists: { byId: {}, order: ["todo", "doing", "review"] },
  cards: {
    byId: {
      "c1": { id: "c1", listId: "todo", title: "Fix login bug", pos: 16384 },
    },
    // per-list sorted arrays, derived: cardsByList[listId] = [c1, c4, ...]
  },
  dnd: { draggedId: null, overListId: null, overIndex: null },
}

Normalization matters here more than usual: a drag mutates two lists' ordering and one card's parent. With a nested tree you'd deep-clone; with byId maps the move is three pointer swaps. Derived selectors (cardsByList) keep render cheap and memoised.

The DragLayer is the architectural trick for 60fps. When a drag starts, the card itself stays in place as a placeholder (or is swapped for a grey ghost), and a separate top-layer clone follows the pointer via transform: translate3d(). Because the clone lives outside the lists' DOM, moving it never triggers layout or re-render of the board underneath โ€” it's pure compositing on the GPU.

D โ€” Data Model

The server is the source of truth; the client cache is a read model with optimistic patches on top.

lists:  id, board_id, title, pos, updated_at
cards:  id, list_id, title, pos, updated_at, version

The single most important field is pos โ€” Trello's real trick. Cards in a list are ordered by a fractional position, not an array index. New card between two neighbours? Insert at the midpoint:

function posBetween(prev, next) {
  if (prev === null && next === null) return 16384;      // empty list
  if (prev === null) return next.pos / 2;                // first
  if (next === null) return prev.pos + 16384;            // last
  return (prev.pos + next.pos) / 2;                      // between
}

Dropping a card between two existing cards is one row updated โ€” no UPDATE cards SET idx = idx + 1 WHERE idx >= 5 renumbering cascade, no lock contention, and a trivial merge story when two users append to the same list at the same time (midpoints converge).

The cost: precision. Halve a gap enough times and floating-point runs out of room. When next.pos - prev.pos drops below a threshold (say 0.002), rebalance that list โ€” assign fresh evenly-spaced positions in one batched transaction. It's rare enough to be cheap, and you mention it unprompted in the interview because it's the follow-up question.

I โ€” Interface

One mutation endpoint does all the moving:

GET    /boards/:id                    โ†’ lists + cards (initial state)
PATCH  /cards/:id/move
       { listId, pos, clientOpId, expectedVersion }
WS     /boards/:id/live               โ†’ other users' changes pushed

clientOpId (a UUID per logical move) gives you idempotency: the network retried the PATCH? The server dedupes instead of double-applying. expectedVersion gives you optimistic concurrency โ€” if the card changed since you last saw it, the server returns 409 and the client decides whether to rebase or re-fetch.

Client-side, the action surface stays tiny:

moveCard(cardId, toListId, toIndex)   // the only write path for drags
createCard(listId, title)
archiveCard(cardId)

Everything funnels through moveCard, which computes pos from the neighbours at toIndex, applies the optimistic state change immediately, and queues the PATCH. One write path means one place for dedup, rollback, and replay logic โ€” that's a testability decision, not laziness.

O โ€” Optimisations

1. Pointer Events, not HTML5 drag-and-drop

The default answer โ€” draggable="true" + dragstart/dragover/drop โ€” is a trap. HTML5 DnD gives you a browser-rendered ghost image you can't style, no touch support on mobile, inconsistent behaviour across browsers, and no control over the drop animation. Trello and every serious implementation use Pointer Events:

card.addEventListener("pointerdown", (e) => {
  startDragSession(card, e);          // record origin, pointerId
  card.setPointerCapture(e.pointerId);
});

document.addEventListener("pointermove", (e) => {
  dragLayer.translateTo(e.clientX, e.clientY);   // GPU transform, 60fps
  updateDropTarget(e.clientX, e.clientY);        // placeholder math
});

setPointerCapture means you get all subsequent moves even if the cursor leaves the card โ€” no "dropped because the pointer outran the event target" bug. This choice alone sets answers apart: HTML5 DnD is the "I've read a tutorial" answer; pointer capture is the "I've shipped this" answer.

2. Drop-target math and the placeholder

On every pointermove, determine the hovered list (cheap: document.elementFromPoint() once, cached per frame) and the insertion index (compare e.clientY against each card's midpoint โ€” a binary search if lists get long). Render a placeholder โ€” an empty gap of the dragged card's exact height โ€” so the remaining cards slide apart to preview the drop. Batch the placeholder update to one per requestAnimationFrame(); recomputing layout per pointermove event (which can fire 120+/sec) is how boards end up janky.

Auto-scroll near list edges: if the pointer is within 40px of a list's top/bottom, scroll that list by a velocity proportional to the depth โ€” that's how long Trello lists are traversed without wheel events.

3. Optimistic moves with rollback

The move is applied locally the frame the pointer releases. The PATCH is debounced ~300ms โ€” drag a card through five lists while hunting for the right one, and the server receives the final position only, not the journey. On failure, roll back to the pre-drag snapshot:

const snapshot = selectCardState(state, cardId);
dispatch({ type: "MOVE_CARD", cardId, toListId, toIndex });
api.moveCard(cardId, ...).catch((err) => {
  if (err.status === 409) return rebaseOrRefresh(cardId);
  dispatch({ type: "RESTORE", cardId, snapshot });
  toast("Move failed โ€” card returned to Doing");
});

Failed moves must be visible (a toast, plus the rollback animation), or the board silently lies about the world.

4. Two users, one card

Real-time sync rides a WebSocket: the server broadcasts committed moves, clients apply them unless the op is their own (the clientOpId echo prevents double-applying). Genuine conflict โ€” two users dragging the same card โ€” resolves server-side by last-write-wins on pos; the loser's card visibly jumps to the winner's position. That's honest UI, and it's fine: collaborative boards tolerate last-write-wins far better than they tolerate lock icons. Mention CRDTs for ordering only if the interviewer pushes; fractional positions already give you most of the merge behaviour at a fraction of the complexity.

5. The animation is the product

When cards slide apart for the placeholder, animate with the FLIP technique (First, Last, Invert, Play): record each displaced card's pre-move position, let layout settle, then transform from old to new position over 150ms. The dropped card lands with a subtle scale-settle (1.02 โ†’ 1). These 150ms are not decoration โ€” perceived responsiveness of a kanban board is almost entirely transition timing.

6. Keyboard drag (the detail nobody prepares)

WAI-ARIA authoring practices define drag-and-drop for keyboards, and Trello implements it: Enter/Space picks up the card (it announces "Picked up Card, position 2 of 7 in To Do"), arrow keys move it between positions and lists, Enter drops, Escape cancels and restores the original position. Fire an aria-live="polite" announcement on each move โ€” "Card moved to Doing, position 3 of 5". A keyboard-inaccessible kanban board is a failed design, and raising it unprompted is a senior signal.

7. Offline queue

Moves made offline append to an IndexedDB outbox keyed by clientOpId; on reconnect, replay in order โ€” idempotency makes replays safe. The board header honestly shows "Offline โ€” 3 changes pending" instead of pretending to be synced.

Interview Gotchas

The questions that separate levels:

  • "How do you order cards in the database?" โ†’ fractional pos (Trello's actual field), midpoint insert, periodic rebalance. If the candidate says "array index", the follow-up about concurrent inserts ends them.
  • "Drag feels laggy on a 200-card board. Why?" โ†’ they're mutating DOM per pointermove instead of batching per rAF, or re-rendering the board instead of transforming a drag layer.
  • "Two users grab the same card." โ†’ last-write-wins on commit, visible correction for the loser, idempotent ops so retries can't double-apply.
  • "The network dies mid-drag." โ†’ the drop still works locally; the PATCH sits in the outbox; the UI flags unsynced state.
  • "Screen-reader user needs to move a card." โ†’ keyboard drag pattern + live-region announcements. Unprompted = senior.
  • "Why not HTML5 drag-and-drop?" โ†’ ghost image, no mobile touch, no drop animation control, cross-browser drift. Pointer capture wins.

Summary

A production-grade kanban board design:

  • Requirements: drag within/between lists, live multi-user, offline survival, keyboard drag, undo โ€” and no renumbering storms
  • Architecture: BoardProvider with normalized byId state; a DragLayer clone that follows the pointer on the GPU while the board beneath never re-renders
  • Data model: cards.list_id + fractional pos (midpoint insert, batched rebalance) โ€” one-row moves, trivial merges
  • Interface: idempotent PATCH /cards/:id/move with clientOpId + expectedVersion; a single moveCard() write path on the client
  • Optimisations: Pointer Events with capture, rAF-batched drop-target math, placeholder + FLIP animations, debounced optimistic moves with visible rollback, WS sync, keyboard drag with live regions, IndexedDB outbox

The board is a rectangle; the hard parts are invisible until two users and a dead network meet on the same card. That's why it's asked.

The Series: Frontend System Design Interview Questions

Donate
ยฉ 2024, Built with Gatsby