Skip to the solution
HardMachine Coding15 min read

Build a Kanban board with drag and drop

1.1k views

The problem

Question presented to candidate: "Build a small Kanban board: three columns, To Do, In Progress and Done, each with a few cards. I want to drag a card to another column or to a new spot in the same column, and add new cards. Users who cannot use a mouse must be able to move cards too. You have about 45 minutes, no libraries."

Requirements:

  • Three columns of cards; dragging a card onto another card inserts it before that card, dropping on empty space appends it.
  • Reordering inside one column works, and dropping a card where it already is changes nothing.
  • A visible drop indicator while dragging, and the dragged card looks picked up.
  • A keyboard path: focus a card, Space to pick it up, arrow keys to move it between and within columns, Space to drop, Escape to cancel.
  • Screen-reader announcements for pick up, move, drop and cancel.
  • An add-card form per column.

What a strong answer should cover:

  • A normalised state model (columns hold ordered card ids, cards live in a lookup) and one pure moveCard function used by both mouse and keyboard.
  • The HTML5 drag-and-drop contract: draggable, dragstart with dataTransfer.setData, preventDefault() in dragover to allow a drop, drop, and dragend for cleanup.
  • The index off-by-one when moving down inside the same column, and the no-op case.
  • Why focus is lost when a card changes column (a new DOM node) and how to restore it after render.
  • Accessibility: aria-roledescription, aria-pressed for the picked-up card, a polite live region, focus-visible styles.
  • Avoiding re-render storms: dragover fires many times a second, so only update hover state when the target changes.

Clarifying questions expected:

  • "Do cards need to persist (server or localStorage), or is in-memory fine?"
  • "Is touch support required? HTML5 drag and drop behaves inconsistently on touch devices, which may mean pointer events instead."
  • "Do columns have limits or rules about which moves are allowed?"

Code / implementation expected: Yes. A working board in the chosen framework with the pure move function, drag handlers, the keyboard mode and the live region.

  • React
  • Drag and Drop
  • State

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read ยท 10 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useEffect, useRef, useState } from "react";
import {
  initialBoard,
  moveCard,
  addCard,
  newCardId,
  findCard,
} from "./src/utils/board";
import Column from "./src/components/Column";

export default function App() {
  const [board, setBoard] = useState(initialBoard);
  const [over, setOver] = useState(null); // { colId, index } under the pointer
  const [draggingId, setDraggingId] = useState(null);
  const [grab, setGrab] = useState(null); // keyboard move: { id, snapshot }
  const [message, setMessage] = useState("");
  const dragId = useRef(null); // read in drop handlers; never rendered
  const cardEls = useRef(new Map());

  // A card that moves to another column is a NEW DOM node, so focus is lost.
  // Put it back on the card that is being moved with the keyboard.
  useEffect(() => {
    if (grab) cardEls.current.get(grab.id)?.focus();
  }, [board, grab]);

  const where = (b, id) => {
    const pos = findCard(b, id);
    const col = b.columns.find((c) => c.id === pos.colId);
    return (
      col.title + ", position " + (pos.index + 1) + " of " + col.cardIds.length
    );
  };

  // ---- pointer path: HTML5 drag and drop ----
  const drag = {
    start(e, id) {
      dragId.current = id;
      if (e.dataTransfer) {
        e.dataTransfer.effectAllowed = "move";
        // Firefox needs data to start a drag
        e.dataTransfer.setData("text/plain", id);
      }
      setDraggingId(id);
    },
    over(e, target) {
      if (!dragId.current) return;
      e.preventDefault(); // without this the browser refuses the drop
      setOver((o) =>
        o && o.colId === target.colId && o.index === target.index ? o : target,
      );
    },
    leave(colId) {
      setOver((o) => (o && o.colId === colId ? null : o));
    },
    drop(e, target) {
      e.preventDefault();
      const id = dragId.current;
      if (!id) return;
      const next = moveCard(board, id, target.colId, target.index);
      setBoard(next);
      setMessage(board.cards[id].title + " dropped in " + where(next, id));
      drag.end();
    },
    end() {
      dragId.current = null;
      setDraggingId(null);
      setOver(null);
    },
  };

  // ---- keyboard path: Space picks up, arrows
  // move, Space drops, Escape cancels ----
  function onCardKey(e, id) {
    const title = board.cards[id].title;
    if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      if (grab) {
        setGrab(null);
        setMessage(title + " dropped in " + where(board, id));
      } else {
        setGrab({ id, snapshot: board });
        setMessage(
          "Picked up " +
            title +
            ". Arrow keys move, Space drops, Escape cancels.",
        );
      }
      return;
    }
    if (!grab || grab.id !== id) return;
    if (e.key === "Escape") {
      setBoard(grab.snapshot);
      setGrab(null);
      setMessage(
        "Move cancelled. " + title + " is back in " + where(grab.snapshot, id),
      );
      return;
    }
    const pos = findCard(board, id);
    const c = board.columns.findIndex((col) => col.id === pos.colId);
    let target = null;
    if (e.key === "ArrowUp" && pos.index > 0) target = [c, pos.index - 1];
    if (
      e.key === "ArrowDown" &&
      pos.index < board.columns[c].cardIds.length - 1
    )
      target = [c, pos.index + 2];
    if (e.key === "ArrowLeft" && c > 0) target = [c - 1, pos.index];
    if (e.key === "ArrowRight" && c < board.columns.length - 1)
      target = [c + 1, pos.index];
    if (!target) return;
    e.preventDefault();
    const next = moveCard(board, id, board.columns[target[0]].id, target[1]);
    setBoard(next);
    setMessage(title + " moved to " + where(next, id));
  }

  function onAdd(colId, title) {
    // outside the updater: StrictMode runs updaters twice
    const id = newCardId();
    setBoard((b) => addCard(b, colId, id, title));
  }

  return (
    <main className="board-app">
      <h1>Sprint board</h1>
      <p id="kb-help" className="hint">
        Drag cards, or focus one and press Space to pick it up, arrows to move,
        Space to drop.
      </p>
      <div className="board">
        {board.columns.map((col) => (
          <Column
            key={col.id}
            col={col}
            cards={board.cards}
            over={over && over.colId === col.id ? over.index : null}
            draggingId={draggingId}
            grabbedId={grab && grab.id}
            drag={drag}
            onCardKey={onCardKey}
            onAdd={onAdd}
            cardRef={(id, el) =>
              el ? cardEls.current.set(id, el) : cardEls.current.delete(id)
            }
          />
        ))}
      </div>
      <p
        role="status"
        aria-live="polite"
        className="sr-only"
        data-testid="live"
      >
        {message}
      </p>
    </main>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 84 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

Build a data-fetching hook with caching, deduplication and retry

Continue