cf.completefrontend
My progress
SYSTEM DESIGN · #26

Design a Kanban board

A reusable board component where cards sit in columns and move by drag and drop, keyboard, or menu. The hard parts are drag mechanics that stay smooth with thousands of cards, an order that survives many people moving cards at once, and a board that works with touch, keyboard, and screen readers.

The question

Design a Kanban board component that a product team can drop into their app, like the board in Trello, Jira, Linear, or GitHub Projects. It shows columns such as To do, In progress, and Done, each holding a list of cards. People drag cards to reorder them inside a column or move them to another column.

Other teams will reuse your component, so walk me through its API, how drag and drop works on mouse, touch, and keyboard, how the new order is saved, and what happens when two people move cards on the same board at the same time.

Real-life examples: Trello board, Jira board, Linear board view, GitHub Projects board, Asana board, Notion board view

Requirements: scoping a board component

A Kanban board looks simple: a few columns, some cards, drag one from here to there. The prompt hides two jobs. One is the component: a piece of UI other teams install, feed with data, and style with their own cards. The other is the product around it: projects, permissions, card pages, notifications.

This guide designs the component. The whole Trello or Jira app is its own problem, so we keep the app around the board thin and design only what the board needs: saving a move and hearing about other people's moves. Start by asking questions; each answer below changes something concrete, so write the assumed answers down before you draw anything.

QuestionAssumed answerWhat changes if the answer is different
Is this a reusable component or one screen in one app?A reusable component for several product teamsOne screen could hard-code card markup and the API. A component needs data in, events out, and render props for cards.
How many cards per board and per column?Up to 2,000 cards per board, up to 1,000 in one backlog columnUnder about 100 cards per column, plain rendering is fine. At 1,000 we need virtualized columns, and drag must work with them.
How many columns? Swimlanes?3 to 15 columns, optional swimlanes (rows grouped by assignee or epic)Swimlanes turn the board into a grid: a drop target is a cell, not a column.
Which devices and inputs?Desktop mouse, laptop trackpad, phones and tablets with touch, keyboard only, screen readersOn touch, a drag competes with scrolling, so the start gesture needs design. Keyboard and screen readers need a full non-pointer path.
Do several people edit the same board at once?Yes, up to about 50 viewers on a busy team boardSingle user means no conflict handling. Many users means live events, conflict rules, and a stable drag while updates arrive.
Who owns the order: the component or the app?The app (controlled), with an uncontrolled mode for simple casesIf the component owned the order, the app could not roll back a failed save or apply remote moves.
Are there limits on a column?Optional WIP limits per column, soft or hardHard limits mean some drops are invalid, and the UI must show why before the drop.
Can the board be filtered or sorted?Filtered by assignee or label; sorting is optionalA filtered or sorted view hides the real order, so dragging needs special rules there.

Functional requirements

  • Render columns of cards from host app data, with the host app deciding what a card looks like.
  • Reorder a card inside its column and move it to another column (and swimlane) with mouse, touch, and keyboard.
  • Offer a non-drag way to move a card: a Move to menu with a column and position.
  • Report every move to the host app as one event with the card, source, destination, and new position.
  • Show WIP limits and refuse or warn on drops that break them.
  • Show other people's moves within about a second, without disturbing a drag in progress.
  • Reorder and rename columns (a smaller version of the same problem).

Non-functional requirements

  • Smooth drag: the dragged card follows the pointer every frame. At 60 frames per second a frame lasts 16.7 ms, and about 10 ms of it is ours (see the runtime-performance lesson).
  • Fast feedback: a drop looks done at once (under 100 ms); the save runs in the background and finishes within about 300 ms on a normal connection.
  • Responsive input: Interaction to Next Paint (INP) at most 200 ms at the 75th percentile, web.dev's threshold for good, even on a mid-range phone with 2,000 cards.
  • Correct order under concurrency: two people moving cards at once never lose or duplicate a card, and never see different orders for long.
  • Accessible: every drag works with the keyboard alone and is announced to screen readers; WCAG 2.2 criterion 2.5.7 Dragging Movements also asks for a single-pointer alternative, which our Move to menu covers.
  • Small and themeable: no card styling of its own and a modest bundle, so host apps can adopt it.

Out of scope

Card pages, comments, attachments, settings, permissions, and notifications belong to the Trello or Jira app problem. We also skip offline editing beyond a short network blip, since long offline sessions on a shared, live board would need a full sync engine. We keep one piece of the backend in scope, the move API and the event stream, because the board's correctness depends on them.

Check your understanding · Exercise 1

Which answers to scope questions would change the design of the board component in a big way? Select all that apply.

What makes a board hard

Before choosing anything, name the traits of the problem. Every later decision comes from one of these five.

Wireframe of the board. A toolbar holds two filter chips: Assignee all and Lanes none, with a note that filters live in the URL. Below are three columns. To do has 4 cards: Login bug, Dark mode, Search shown faded because it is being dragged, and Export. Doing shows 2 of a WIP limit of 3, with API docs, Cache, and a dashed placeholder where the card will land. The drag overlay, a lifted copy of Search, floats over the placeholder. Done has Onboarding and Icons and empty space that still accepts drops.Board: Web teamAssignee: allLanes: nonefilters live in the URLTo do 4Doing 2 / 3Done 2Login bugDark modeSearchExportcards: renderCardAPI docsCacheplaceholderSearchdrag overlayOnboardingIconsempty spacestill a target
Name the parts before designing them: columns with counts and WIP limits, cards drawn by the host app, the placeholder that shows the landing slot, and the overlay that follows the pointer while the original stays faded in place.
  1. Direct manipulation. The user holds a card and expects it to stick to their finger or cursor; lag of more than a frame or two feels broken in a way a slow page load does not. So drag is a performance problem first: decide what runs on every pointermove and keep it tiny.
  2. Many inputs for one action. The same move must work by mouse, trackpad, touch (where a drag fights with scrolling), keyboard, screen reader, and a plain menu. They need one shared model of a move, or they drift apart.
  3. Geometry. Many times per second the board must answer: given the pointer, which column and which slot would the card land in? Cards differ in height, columns scroll on their own, the board scrolls sideways, and with virtualization most cards are not in the DOM.
  4. Order is shared data. The order of cards is saved on the server and seen by everyone. Saving it as positions 1, 2, 3 means one drag rewrites many rows, and two people dragging at once overwrite each other.
  5. It is a component. Other teams decide what a card looks like, where data comes from, and whether a move is allowed, and the API must give them that control without making them rebuild drag and drop.

A useful way to say this in an interview: the board has a fast loop and a slow loop. The fast loop runs during a drag, 60 times per second, entirely on the client: read the pointer, find the target, move a transform. The slow loop runs once per drop: compute a new rank, update the store, call the server, reconcile with other people's changes.

Keeping the two loops apart is the core of the design. The fast loop must never wait on the network or on React re-rendering the whole board, and the slow loop must never assume the board still looks the way it did when the drag started.

Think first

A teammate proposes saving the new order to the server on every pointermove so that other people see the card move live. What goes wrong?

The component API: data in, moves out

Start with the API, because it fixes the boundary between our component and the host app. The naive version takes an initial list of cards, lets the user drag them around in its own state, and calls onChange(newCards) with the whole array. It demos well and breaks in three places.

  • Rollback is impossible. The save fails, but the component already holds the new order, so the app cannot put the card back without remounting the board.
  • Remote moves fight local state. Another user moves a card and the app passes new props, but the component ignores them because it only read the initial list.
  • The diff is lost. The app must compare 2,000 cards to find which one moved and where, just to call a move API that needs one card id and one position.

Decision Who owns the order of cards?

Uncontrolled: the board owns it

The app passes defaultColumns once.

The board keeps order in internal state and reports changes after the fact.

Pros

  • Easiest to start with: one prop and it works, fine for demos, personal boards, and tests.

Cons

  • The app cannot roll back, apply remote changes, or veto a move after it happens.
  • Two sources of truth as soon as the app also stores cards.

Controlled: the app owns it

The app passes columns (with card ids in order) on every render.

The board never changes order itself; it calls onMove and waits for new props.

Pros

  • One source of truth, so rollback and remote updates are just new props.
  • Works with any store: a server cache, a normalized client store, a CRDT.

Cons

  • More wiring: the app must update its state on onMove, quickly, or the card snaps back or lags.

Both, React style

If columns is passed, the board is controlled; if only defaultColumns, it is uncontrolled.

Same onMove event in both modes.

Pros

  • Simple cases stay simple, real apps get full control.
  • Matches React's own value and defaultValue inputs, so it feels familiar.

Cons

  • Two code paths to test, and switching modes mid-life needs a dev-time warning, as React inputs give.

What we pick: Offer both, with controlled as the documented path for anything that saves to a server. In either mode the board keeps one small piece of local state, the drag in progress (which card, where it hovers): UI state, not data, gone on drop.

Props and the move event

Data goes in as ids, not nested objects: columns hold an ordered list of card ids, and cards live in a separate map keyed by id. This mirrors the normalized store the app probably has (see the normalization lesson), makes a move a change to two short id arrays, and lets the board skip cards whose objects did not change.

tsx
type MoveEvent = {
  cardId: string;
  from: { columnId: string; laneId?: string; index: number };
  to: { columnId: string; laneId?: string; index: number };
  // Neighbours at the destination, so the app can compute a rank.
  beforeId: string | null;
  afterId: string | null;
  source: "pointer" | "keyboard" | "menu";
};

type KanbanBoardProps<Card> = {
  columns: readonly { id: string; title: string; cardIds: readonly string[]; wipLimit?: number }[];
  cards: Readonly<Record<string, Card>>;
  lanes?: readonly { id: string; title: string }[];
  getLaneId?: (card: Card) => string;
  renderCard: (card: Card, state: { isDragging: boolean; isOverlay: boolean }) => React.ReactNode;
  onMove: (move: MoveEvent) => void;
  canDrop?: (move: MoveEvent) => true | { reason: string };
  getCardLabel: (card: Card) => string; // used in announcements
  dragDisabled?: boolean | { reason: string };
};
The public props, trimmed to the parts that shape the design.

Five details in that shape matter.

  • from and to carry indexes, and the event also carries beforeId and afterId. Indexes suit a local array splice. Neighbour ids are what the server needs, because indexes go stale the moment someone else moves a card (more in the ordering chapter).
  • renderCard is a render prop. The board owns the wrapper that receives pointer and keyboard events and the drag transform; the app owns everything inside. The state argument says whether it is drawing the faded original or the lifted copy, so the app can, say, drop a heavy avatar list from the copy.
  • canDrop vetoes a move before the drop, with a reason the board can show and announce. WIP limits and permission rules plug in here.
  • getCardLabel exists because the board must speak about cards to screen readers but does not know what a card is.
  • source lets analytics see how people really move cards.

WIP limits and swimlanes in the API

A WIP limit (work in progress limit) caps how many cards a column should hold, a core Kanban idea: In progress with a limit of 3 pushes the team to finish work before starting more. Jira turns an over-limit column red but still allows the move, while a stricter team may want a hard stop. So wipLimit gives the soft version for free (the header shows 4 / 3 in a warning colour), and a hard limit is a canDrop rule the app writes: one mechanism, two policies.

Swimlanes are horizontal bands across all columns, for example one per assignee or per epic. They turn a destination from a column into a cell (column plus lane), which is why from and to carry an optional laneId. Moving a card to another lane usually changes a field on the card, such as its assignee, so the app decides what a lane move means; the board only reports it.

Check your understanding · Exercise 2

A host app uses the board in controlled mode. The user drops a card, the app's save fails, and the app wants the card back where it was. Which statements are true? Select all that apply.

The drag engine: pointer events and the drag lifecycle

The browser ships a drag-and-drop API: put draggable="true" on an element and listen for dragstart, dragover, and drop. It is the obvious first choice, and for years most board libraries chose not to use it. To see why, try the naive version on our requirements.

  • The preview is a picture. The browser shows a snapshot of the element taken at dragstart (swappable with setDragImage). It is not a live DOM element, so you cannot animate it, tilt it like Trello does, or update a badge on it.
  • Touch arrived late. For years mobile browsers did not start HTML5 drags from touch. Safari on iOS 15 and later and current Chrome on Android do, but the browser controls the start gesture, the preview, and how it interacts with scrolling.
  • No pointer moves during the drag. When a native drag starts, the browser fires pointercancel and stops sending pointer and mouse events; you only get drag and dragover, where you can read the dragged data's types but not the data.
  • No keyboard path. A native drag cannot start from the keyboard, so you must build a second system for accessibility anyway.
  • Quirks. Every drop target must call preventDefault() in dragover or the drop is refused, dragenter and dragleave fire for every child element you cross, and defaults (a dropped link opens a page) need suppressing.

Decision Which engine drives the drag?

HTML5 drag and drop, raw

draggable on cards, dragover and drop on columns; the browser draws the preview and owns the gesture.

Pros

  • No library and little script while dragging.
  • Can drag across windows and accept files or links from outside the page.

Cons

  • Static preview, quirks, no keyboard path, and other cards are hard to animate out of the way.

Pointer events, own engine

pointerdown on the card, setPointerCapture, then follow pointermove and move a live overlay with transform.

Find the target with your own geometry, as dnd-kit does with its pointer sensor and collision functions (react-beautiful-dnd used mouse and touch events).

Pros

  • Nearly one code path for mouse, pen, and touch.
  • Live, styled preview and full control of thresholds, animations, and auto-scroll.
  • Same model as the keyboard path: both produce a position.

Cons

  • You own the hard parts: scroll handling, hit testing, pointercancel.
  • No dragging between windows or accepting external files without the native API.

Native drag and drop through a library

A library such as Atlassian's Pragmatic drag and drop wraps the native API and smooths its quirks, with optional packages for drop indicators, auto-scroll, and accessible alternatives.

Pros

  • Very small core (about 4.7 kB, per Atlassian's docs) and little script on the main thread during a drag.
  • Works with any framework, with external files, and across windows.

Cons

  • Still a static browser preview.
  • Accessibility comes from separate controls such as a move menu, not keyboard dragging.

What we pick: For a reusable board where touch and a live preview matter, build on pointer events, and treat keyboard and menu moves as other inputs that produce the same move. Atlassian's own path is the honest counterpoint: it deprecated react-beautiful-dnd (its own engine on mouse, touch, and keyboard events) in October 2024, archived it in 2025, and now recommends Pragmatic drag and drop, which uses the native API and offers accessibility through action menus. If the board must accept files from the desktop, add a native drop handler for that case only.

Two drag engines side by side. Left, HTML5 drag and drop: dragstart takes a setDragImage snapshot, then the browser runs the drag and the preview is a picture, then dragover and drop fire on targets, which must call preventDefault to allow a drop. Right, pointer events: pointerdown with setPointerCapture, then our engine runs each frame and finds the slot from rectangles, then a live overlay and a slot that the keyboard can produce too.HTML5 drag and dropdragstartsetDragImage snapshotbrowser dragpreview is a picturedragover, droppreventDefault to allowpointer eventspointerdownsetPointerCaptureengine, per frameslot from rectanglesoverlay + slotlive, same as keyboard
The native API hands the gesture and the preview to the browser. A pointer engine keeps both, which costs more code but gives a live overlay and a slot that keyboard moves share. Listen for pointercancel on the right side, or a scroll takeover looks like a drop.

Getting pointer events right

Pointer events unify mouse, pen, and touch, but a few details decide whether the drag works on real devices.

  • Capture the pointer. Call element.setPointerCapture(event.pointerId) on pointerdown, so pointermove and pointerup keep arriving after the pointer leaves the card or the window.
  • Tell the browser who owns the gesture. On touch the browser wants to scroll, and pointer listeners cannot stop it: once it starts panning it fires pointercancel, and changing touch-action after pointerdown has no effect. Put touch-action: none on a drag handle if there is one. If the whole card is draggable, keep scrolling enabled, lift only after a long press, then call preventDefault() in a non-passive touchmove listener, as dnd-kit's touch sensor does.
  • Handle pointercancel. The browser fires it when it takes over the gesture (for scrolling or a system dialog). Treat it as a cancel, never as a drop.
  • Only the primary button. Start a drag only when event.button === 0, so right-click menus and middle-click still work.
  • Not on interactive children. A pointer down on a link, checkbox, or text field inside a card should do what it normally does, so the engine checks event.target.closest(...) against a list of interactive elements.

Check your understanding · Exercise 3

Why do many board libraries build drag on pointer events instead of the HTML5 drag-and-drop API? Select all that apply.

The drag lifecycle as a state machine

The naive engine has two states: not dragging, and dragging from pointerdown until pointerup. On a real board that breaks at once: the 2 px a hand shakes during a click starts a drag, a swipe to scroll a column lifts the card under the thumb, Escape halfway still drops the card, and the card teleports into place so the eye loses it. Each of these is a missing state.

State machine of a drag. From idle, pointerdown goes to pending. From pending, a click or a scroll returns to idle, and moving 5 px or a long press goes to dragging. From idle, Space on the keyboard goes straight to dragging. From dragging, pointer up or Space goes to dropping, which animates in and then returns to idle. Escape, pointercancel, or an invalid target goes to cancelling, which animates back and then returns to idle.pendingidledraggingcancellinganimate back, then idledroppinganimate in, then idlepointerdownclick or scrollmoved 5 pxor long pressSpace (keyboard)up or SpaceEscape or invalid
Pending is the state most hand-built boards forget: it is what lets a click open a card and a swipe scroll a column. Keyboard drags skip it, because a key press is already a clear intent.
  • idle: nothing is happening. A pointerdown on a card moves to pending. Space on a focused card goes straight to dragging, because a key press is already a clear intent.
  • pending: the pointer is down, but this may be a click, a scroll, or a drag. A mouse lifts once it moves a few pixels (react-beautiful-dnd used 5 px, and so do we). Touch lifts only after a long press with little movement: we use 250 ms and 5 px, as in dnd-kit's docs, while react-beautiful-dnd used 120 ms. Moving past the tolerance first means a scroll, and releasing first means a click; both return to idle.
  • dragging: the original stays in the column as a faded placeholder and an overlay copy follows the pointer; every move updates the target. Escape, pointercancel, a remote delete of the card, or a release over an invalid target goes to cancelling. A pointer up or a second Space goes to dropping.
  • dropping: the overlay animates to its new slot for about 150 to 250 ms. The move event fires as this state starts, so the store updates during the animation; then idle.
  • cancelling: the overlay animates back to the original slot, no move event fires, then idle.

Write this as a real reducer, not as flags spread across event handlers. Flags like isDragging, isPending, and wasCancelled can combine into states that make no sense, such as pending and dropping at once. A reducer makes impossible states impossible and every transition testable without a browser: feed it events, check the state.

ts
type Point = { x: number; y: number };
type Slot = { columnId: string; laneId?: string; index: number };

type DragState =
  | { phase: "idle" }
  | { phase: "pending"; cardId: string; start: Point; pointerType: string; startedAt: number }
  | { phase: "dragging"; cardId: string; origin: Slot; over: Slot | null; offset: Point; input: "pointer" | "keyboard" }
  | { phase: "dropping"; cardId: string; to: Slot }
  | { phase: "cancelling"; cardId: string; origin: Slot };

type DragEvent =
  | { type: "DOWN"; cardId: string; at: Point; pointerType: string }
  | { type: "MOVE"; at: Point }
  | { type: "LONG_PRESS" }
  | { type: "LIFT_KEY"; cardId: string }
  | { type: "KEY_MOVE"; to: Slot }
  | { type: "UP" }
  | { type: "CANCEL" }
  | { type: "ANIMATION_END" };
The drag state as a discriminated union: each state carries only the data it needs.

Edges the machine must handle

  • The click after a drag. Browsers fire click after pointerup, so a drag that ends on the same card would open it. Swallow the next click when leaving dropping or cancelling.
  • Text selection. Set user-select: none on the board during pending and dragging, and clear any selection that started.
  • Context menu on long press. A held finger also triggers the browser's context menu or text callout. Prevent contextmenu on cards while pending, and set -webkit-touch-callout: none on cards.
  • Haptic and visual cues. When a touch drag lifts, scale the card up slightly, and on Android call navigator.vibrate(10) where supported, so the user knows the long press worked before they move.
  • Reduced motion. If prefers-reduced-motion: reduce matches, skip the drop and cancel animations but keep the overlay movement, which follows the user's own hand and is not decorative.

Check your understanding · Exercise 4

On a phone, a user puts a finger on a card and immediately swipes up 40 px within 80 ms. What should the machine do? Select all that apply.

Finding the drop target and auto-scrolling

While dragging, the board answers one question every frame: if the user let go now, which column (and lane) and which index would the card land at? The answer drives the placeholder, the announcement, and the final move.

The naive answer asks the DOM: call document.elementFromPoint(x, y) and walk up to the nearest card or column. It returns the overlay itself unless the overlay has pointer-events: none, and nothing when the pointer sits in the 8 px gap between cards, so the placeholder flickers. It cannot see cards that virtualization removed, and the keyboard has no pointer to ask about.

The better plan works on numbers. At drag start, measure each column's and each visible card's rectangle once with getBoundingClientRect(), keep them in a plain array, and answer the question with arithmetic. The open choice is which arithmetic.

Decision How do we decide what the dragged card is over?

Pointer within

The target is whichever rectangle contains the pointer.

dnd-kit names this pointerWithin.

Pros

  • Precise and predictable: where you point is where it goes.

Cons

  • No answer in gaps and margins, so it needs a fallback.
  • Means nothing for keyboard drags, which have no pointer.

Closest center

Compare the center of the dragged card's rectangle with the center of every target; the nearest wins.

dnd-kit's closestCenter, its recommended default for sortable lists.

Pros

  • Always has an answer, so no flicker in gaps, and the user need not aim exactly.

Cons

  • With nested targets (a column containing cards) the column's center can win over a card inside it, which dnd-kit's docs call out for Kanban layouts.
  • A wide overlay held by its corner targets something far from the pointer.

Two-step: column by pointer, slot by midpoints

Step 1: the column (and lane) whose rectangle contains the pointer; if none, the nearest by horizontal distance.

Step 2: inside that column, the index is the number of cards whose vertical midpoint is above the pointer's y.

Pros

  • Each step uses the measure that fits it, so no nested-target surprises.
  • The keyboard produces the same result by setting the column and index directly.

Cons

  • Custom code instead of a library default.
  • Needs a small dead zone to stop flicker at a midpoint.

What we pick: Use the two-step approach. It reads the way people think ("I am over In progress, between card 3 and card 4"), always has an answer, and gives keyboard and pointer one shared result type, a slot. In dnd-kit, the same idea is a custom collision function that tries pointerWithin for columns and falls back to closestCorners, which its docs suggest for Kanban boards.

Two columns, To do and Doing. A wide card, Dark mode, is held by its left edge. The pointer is inside To do at y 214, but the card's center is inside Doing. Pointer within picks To do, closest center picks Doing, and the two-step rule picks To do. In To do, the card midpoints at 104 and 166 are both above the pointer, so the slot index is 2.To doDoingLoginSearchAPI docsCacheDark modecenterpointerpointer: To docenter: Doingtwo-step: To domidpoints 104, 166are above y 214so slot index 2
The dragged card's center can sit in a different column from the pointer when the card is wide or held near an edge. Picking the column by the pointer matches what the user aims at; the slot then comes from counting midpoints above the pointer.

Worked example: the slot inside a column

A column's cards have these midpoints, measured from the top of the column's scroll content: 40, 130, 250, 330 (heights differ because titles wrap). The pointer is at y = 200 in the same coordinates, so two midpoints (40 and 130) are above it and the index is 2: between the second and third cards. At y = 260, three midpoints are above, so the index becomes 3. The rectangles were measured once, so when the column scrolls, add the change in scrollTop to the pointer instead of measuring again.

Two refinements make it feel steady. Hysteresis: change the index only when the pointer is more than about 8 px past a midpoint, so a hand resting on a boundary does not make cards jump back and forth. Skip the dragged card itself in its home column: its old rectangle is now the placeholder, and counting it would shift later indexes by one.

Placeholders and moving between columns

The placeholder is an empty box with the dragged card's height, shown where the card would land. In the home column, the cards between the old and new index slide with transform to open the gap at the target. When the card moves to another column, the home column closes its gap and the target column opens one, again with transforms; the real DOM order changes only on drop, so the browser does not re-run layout for whole columns on each move.

Empty columns need their own target: the body of an empty column, at least one card tall, counts as index 0. Without it, nobody can drop into a new, empty Done column, the most common bug in hand-built boards. With swimlanes the two-step runs on a grid: the pointer's x picks the column, its y picks the lane, and the midpoint rule runs on the cards in that cell.

Check your understanding · Exercise 5

A user drags a wide card by its left edge toward a narrow column on its left. The pointer is clearly inside column B, but the card's center is still over column A. What do the strategies pick? Select all that apply.

Auto-scroll: reaching columns and cards you cannot see

A board with 12 columns of 280 px each, plus gaps, is about 3,600 px wide, and a laptop window shows about 1,400 px of it; a backlog of 300 cards is many screens tall. So most drags end somewhere that was off screen when they started, and the user cannot scroll by hand while holding a card. The board must scroll for them when the pointer nears an edge.

The naive version scrolls by a fixed 10 px inside the pointermove handler whenever the pointer is within 50 px of an edge. Two bugs follow. When the user holds still at the edge, no pointermove fires, so scrolling stops exactly when they want it most. And speed depends on how much the hand shakes: a twitchy mouse scrolls fast, a steady one does not.

A frame loop with speed by distance

Instead, run a requestAnimationFrame loop while the pointer is inside an edge zone, reading the last known pointer position each frame. Speed grows as the pointer nears the edge, so the user controls it: hover near the zone's inner border to creep, push to the very edge to fly.

ts
const MAX_SPEED = 1200; // px per second at the very edge

function edgeSpeed(distance: number, zone: number): number {
  if (distance >= zone) return 0;
  const closeness = (zone - Math.max(distance, 0)) / zone; // 0 at zone border, 1 at edge
  return MAX_SPEED * closeness * closeness; // ease in: slow near the border
}

function step(el: HTMLElement, p: { x: number; y: number }, dtMs: number) {
  const r = el.getBoundingClientRect(); // cached per drag in practice
  const zoneX = Math.min(120, r.width * 0.2);
  const right = edgeSpeed(r.right - p.x, zoneX);
  const left = edgeSpeed(p.x - r.left, zoneX);
  const dx = ((right - left) * dtMs) / 1000;
  const canScroll = dx > 0
    ? el.scrollLeft + el.clientWidth < el.scrollWidth
    : el.scrollLeft > 0;
  if (dx !== 0 && canScroll) el.scrollLeft += dx;
  return dx !== 0 && canScroll; // keep looping while true
}
One frame of auto-scroll. Speed is in px per second, scaled by the real frame time so 60 Hz and 120 Hz screens scroll at the same rate.

Work the numbers with a 120 px zone. At 90 px from the right edge, closeness is 30 / 120 = 0.25 and speed is 1,200 x 0.0625 = 75 px per second, a gentle creep. At 20 px, closeness is about 0.83 and speed about 830 px per second, a 280 px column every third of a second. The squared curve keeps the outer zone calm, so a card passing near the edge on its way to a nearby column does not yank the board sideways.

The zone width, top speed, and curve are tuning choices, not standards. react-beautiful-dnd used the same squared curve, starting 25 percent of the container from the edge and capping at 28 px per frame (about 1,700 px per second at 60 Hz); Pragmatic drag and drop's auto-scroller also speeds up toward the edge and adjusts for the screen's frame rate.

Top: a board viewport with columns and a 120 px edge zone on the right. The pointer is 20 px from the edge, which scrolls at about 830 px per second. Bottom: a graph of speed against closeness to the edge. Speed is 0 at the inner border of the zone, 75 px per second at 90 px away, 300 at 60 px, 675 at 30 px, and 1200 at the edge.edge zone120 px20 px from edge: 830 px/spx/s753006751200closer to the edge
Speed follows the square of how deep the pointer is in the zone, so the outer part of the zone barely moves the board and the last 30 px move it fast. The loop runs on animation frames, so holding still keeps scrolling.

Which container scrolls

  • Nested scrollers. The pointer can be near a column's bottom edge and the board's right edge at once. Walk from the innermost scroll container under the pointer outward and scroll every one whose edge zone holds the pointer and that can still scroll that way; a column already at its bottom passes the turn to the page.
  • Do not race on the first frame. A drag that starts near an edge (common in the last column) should not fly sideways before the user does anything, so ramp the speed up over the first few hundred milliseconds. react-beautiful-dnd dampened speed for the first 1.2 s, and Pragmatic drag and drop applies time dampening too.
  • Keep the target honest. Scrolling moves cards under a still pointer, so on scroll shift the cached rectangles by the delta and run target detection again, even though no pointermove fired. Virtualized columns also mount new cards as they scroll, which need measuring (see the performance chapter).
  • Stop cleanly. Cancel the frame loop on drop, on cancel, and when the pointer leaves all zones, or a stale loop keeps scrolling after the drag.
Input
Strategy
Held by

Move the pointer sliders, or switch to keyboard and press Space on the card button.

A board of five columns seen through a 600 wide viewport scrolled 0 px. Pointer within: Doing. Closest center: Doing. Two-step: Doing. Auto-scroll: still. Target: Doing, slot 2.To do 2Login bugDark modeDoing 2/3API docsCacheReview 1/1IconsSearch
Pointer within: Doing. Closest center: Doing. Two-step: Doing. Auto-scroll: still. Move the pointer sliders, or switch to keyboard and press Space on the card button.

Check your understanding · Exercise 6

A user drags a card to the right edge of the board and holds still. The board scrolls, but the placeholder stays in the column that was under the pointer when it stopped moving. What is the bug, and what fixes it? Select all that apply.

Rendering at 60 frames per second during a drag

The fast loop gets about 16.7 ms per frame at 60 Hz, and after style, layout, and paint about 10 ms is left for our script; a 120 Hz frame is only 8.3 ms in total. A naive React board blows this budget without anyone noticing on a developer laptop with 30 cards.

The naive version keeps the pointer position and the target in React state at the board root, shared through context. Every pointermove calls setState and re-renders the whole board: 15 columns and 2,000 cards. If a card with an avatar, labels, and a title takes 0.05 ms to re-render on a mid-range phone (a rough estimate), that is 100 ms per move: the overlay falls behind the finger, frames drop, and the drop's INP suffers.

Decision How do updates during a drag reach the screen?

React state at the root

Pointer position and target in a context at the top.

Every card reads the context to decide whether to shift.

Pros

  • Simple and declarative.
  • Fine for 20 or 30 cards.

Cons

  • Every move re-renders every card that reads the context, so cost grows with board size, not with what changed.

Store with fine-grained selectors

Drag state lives in a small external store (for example one read with useSyncExternalStore).

Each card subscribes to its own derived value: "how far should I shift?"

Pros

  • Only cards whose shift changes re-render, usually the 1 to 10 between the old and new target.

Cons

  • Each card runs its selector on every store change, cheap but not free at 2,000 cards.
  • Selectors must return stable values.

Split: imperative overlay, declarative slots

The overlay's position is written straight to element.style.transform in a requestAnimationFrame callback, never through React.

Only the target slot goes into the store, when it changes: a few times per second, not 60.

Pros

  • The 60 per second part costs one style write per frame.
  • React work happens only when the visible result changes.

Cons

  • Two update paths to keep in sync, and the overlay is cleaned up by hand on drop and cancel.

What we pick: Combine the second and third: the overlay moves imperatively each frame, the slot lives in a store, and cards subscribe to their own shift. Good libraries behave the same way; dnd-kit, for example, shifts sortable items with transforms instead of re-rendering lists in a new order during the drag.

Bars comparing work per update with the 16.7 ms frame budget at 60 Hz. Root state that re-renders 2,000 cards on every move takes about 100 ms. Moving the overlay with a transform takes about 0.1 ms. A slot change that re-renders 3 cards takes about 1 ms and happens only a few times per second. These are estimates for a mid-range phone.frame budget, 60 Hz16.7 msroot state, every move~100 msoverlay transformabout 0.1 ms, every frameslot change, 3 cardsabout 1 ms, a few times a secondrough estimates for a mid-range phone
The naive version spends six frames of time on every pointer move. The split version spends almost nothing per frame and does real React work only when the slot changes.

Rules for the fast loop

  • Transforms only. Move the overlay and shift cards with transform: translate(...), which the browser can usually composite without layout; never animate top, left, margin, or height during a drag. Promote only the overlay with will-change: transform; 2,000 promoted cards would waste GPU memory.
  • One update per frame. Pointer events can fire more often than the screen refreshes, so store the latest position and process it once inside requestAnimationFrame.
  • Read, then write. Measure all rectangles at drag start in one batch, before any style change, because reading a rectangle after writing a style forces a synchronous layout (layout thrashing). During the drag, read the DOM only on scroll and when new virtualized cards mount.
  • Portal the overlay. Render it in a layer at the end of body (React's createPortal), so a column's overflow: auto does not clip it and its transform is relative to the viewport.
  • Memoize cards. Wrap the card component in React.memo, so a store change that does not touch a card skips it. Normalized props make this work, because unchanged cards keep the same object.

Drag inside virtualized columns

A backlog of 1,000 cards should not create 1,000 DOM subtrees. A virtualized column renders only the cards in view plus a small overscan, about 20 to 30 cards in all, positioned from a list of heights (see the virtualization lesson). Drag and drop assumes the opposite, that every target is in the DOM to measure, so three things need explicit design.

  1. Targets from the virtualizer, not the DOM. The virtualizer knows each card's offset and height (measured for rendered cards, estimated for the rest), so compute midpoints from those numbers. With estimated heights the slot may be slightly off until the card renders, which is acceptable because the user sees the placeholder and adjusts.
  2. The placeholder is a virtual item. Insert a fake item with the dragged card's height into the target column's item list, so later offsets and the scrollbar stay honest, and remove it from the home column the same way.
  3. Keep the dragged card alive. If auto-scroll carries the original slot out of view, the virtualizer unmounts it. The portal overlay survives, but in a keyboard drag focus sits on the original, and removing a focused element sends focus to body. Pin the dragged card's index with a custom range function (TanStack Virtual calls it rangeExtractor); react-beautiful-dnd solved the same problem with a separate clone renderer for virtual lists.
A virtualized column of 1,000 cards during a drag. Cards 0 to 2 are not in the DOM. Card 3, the dragged card, is pinned and stays mounted for focus. Cards 4 to 37 are not in the DOM. The viewport with overscan renders cards 38 and 39, then a placeholder inserted as a virtual item, then cards 40 and 41. Cards 42 to 999 are not in the DOM. A note says target offsets come from the virtualizer's sizes, not the DOM.0 to 2: no DOMcard 3dragged, pinned for focus4 to 37: no DOMcard 38card 39placeholdercard 40card 4142 to 999: no DOMviewport + overscanabout 20 to 30 mountedslot offsets comefrom the virtualizersizes, not the DOM
Only about 20 to 30 cards exist in the DOM. The drag reads slot positions from the virtualizer's size list, adds the placeholder as one more virtual item, and pins the dragged card so keyboard focus survives scrolling.

Check your understanding · Exercise 7

A profile of a drag on a 2,000 card board shows 45 ms frames. Which changes would help most? Select all that apply.

Saving the order: positions, ranks, and rebalancing

The slow loop starts with a question the drag does not answer: how is "card 42 now sits between card 7 and card 9" stored, so that everyone sees the same order after reload?

The naive answer stores an integer position per card: 0, 1, 2, and so on per column. Moving a card from position 50 to 2 shifts cards 2 to 49 down by one, so one drag writes 49 rows. Concurrency is worse: Asha moves card A to position 2 while Ben moves card B to position 3, both from the same old view, and each request renumbers the column from its own picture. The second write undoes part of the first, two cards can share a position, and clients disagree until reload.

Decision How do we store card order?

Integer positions

position is 0, 1, 2 within a column; a move renumbers every card between the old and new slot.

Pros

  • Easy to read and sort, and index equals position.

Cons

  • One move writes up to the whole column.
  • Concurrent moves overwrite each other's renumbering.

Linked list

Each card stores the id of the card after it; a move rewrites three pointers (old neighbour, new neighbour, the card).

Pros

  • Constant writes per move, and no numbers to run out of.

Cons

  • Sorting needs a walk through the list, and one lost pointer breaks the column.
  • Two concurrent inserts after the same card fork the list and need a merge rule.

Fractional index as a number

Each card has a float rank; a move sets the rank to the average of its new neighbours.

Between 1 and 2 goes 1.5, then 1.25, and so on.

Pros

  • One row written per move.
  • Sorting is a plain numeric sort.

Cons

  • A 64-bit float has 52 bits of fraction, so about 50 inserts into the same gap exhaust it and two cards get equal ranks.

Fractional index as a string (LexoRank style)

Each card has a string rank compared character by character; a move generates a string between its neighbours' strings.

Strings can always grow longer, so there is always room. Jira's LexoRank works this way.

Pros

  • One row written per move, and the request needs only the card and its new neighbours.
  • Concurrent moves of different cards never touch each other's rows.

Cons

  • Strings grow when many inserts hit the same gap, so rebalancing is needed now and then.
  • Two clients can generate the same string for the same gap; ties need a rule.

What we pick: Use string fractional ranks. Every move becomes a single-row write, which is what makes concurrency manageable, and the approach is proven: Jira ranks issues with LexoRank, and Figma has written about fractional indexing for ordering layers. Rebalancing handles the cost, growing strings.

Two rows of fractional ranks. Top: Login has rank c and Search has rank i; a card dropped between them gets f, one character, and nothing else changes. Bottom: six cards dropped one after another right after the card ranked i, each into the gap the last one made, get ii, i9, i5, i3, i2, and i1, so the sorted column reads i, i1, i2, i3, i5, i9, ii, j. A note says Jira schedules a rebalance at 128 characters and blocks ranks of 254.spread out: one character is enoughcfiLogindroppedSearchsame gap, six drops in a rowii1i2i3i5i9iijeach drop halves the gap: about one more character every five or six dropsJira: rebalance at 128 characters, blocked at 254
A drop writes one rank and leaves every other card alone. Keys only grow when many drops hit the same gap; rebalancing rewrites them short again without changing the order. Strings compare character by character, so i1 sorts before i2 and both before ii.

How a key between two keys is made

Think of a rank as the digits after a decimal point, in base 36 (0 to 9, then a to z). Between i and r the midpoint digit is n. Between i and j there is no digit left, so add one: ii sits between them, because i is a prefix of ii and ii is less than j; between i and ii come i9, then i5, i3, i2, i1.

Each insert into the same gap roughly halves it, costing about one bit, and a base 36 character holds about 5 bits. So a key grows by one character every five or six inserts into the same spot, while inserts spread across the column barely grow keys. Real implementations use a bigger alphabet: the notion and figma guides show base 62 keys with a short integer part, like a0 and a0V.

ts
const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";

export function rankBetween(a: string, b: string | null): string {
  if (b !== null) {
    let n = 0;
    while ((a[n] ?? "0") === b[n]) n++; // shared prefix
    if (n > 0) return b.slice(0, n) + rankBetween(a.slice(n), b.slice(n));
  }
  const da = a ? DIGITS.indexOf(a[0]) : 0;
  const db = b !== null ? DIGITS.indexOf(b[0]) : DIGITS.length;
  if (db - da > 1) return DIGITS[Math.round((da + db) / 2)];
  if (b !== null && b.length > 1) return b[0];
  return DIGITS[da] + rankBetween(a.slice(1), null);
}

rankBetween("", null); // "i": first card in an empty column
rankBetween("i", null); // "r": append at the end
rankBetween("i", "j"); // "ii": no digit between, so go one level deeper
A key strictly between two keys. An empty string means the start of the column, null means the end. Keys never end in 0, so there is always room before any key.

What the move request carries

The client sends the card id, the destination column (and lane), and the ids of the new neighbours, beforeId and afterId; it sends neither a list of positions nor a rank. The server looks up the neighbours' current ranks, computes the new rank, writes one row, and returns it.

Neighbours matter under concurrency. If someone moved card 9 a second ago, a rank computed from the client's stale picture could misplace the card, while neighbour ids say what the user meant: "right after card 7". The client still computes a rank locally with the same function, so the optimistic card sorts into place before the server answers.

Rebalancing

Some columns get abused: a team that always drags new work to the top of the backlog inserts into the same gap hundreds of times, and keys there grow long. Rebalancing rewrites every rank in a column (or board) to evenly spaced short strings in the same order. Jira documents its LexoRank limits on Data Center: at 128 characters a rebalance is scheduled within 12 hours, at 160 one starts immediately, and ranking operations that would produce 254 characters or more are blocked.

Jira's rank values also start with a bucket number (0|, 1|, or 2|). A rebalance migrates the ranks, rewritten short, into the next bucket, so normal ranking keeps working while it runs, as Atlassian's docs note.

For the client, ranks are opaque and may change for reasons other than a move, so never animate a rank change as a move. When the server announces a rebalance, accept the new ranks in bulk and re-sort; the order stays the same, so nothing visible changes. A rebalance during a drag does not affect it either, because the drop sends neighbour ids, not ranks.

Rank from

Pick a card and move it. Each move writes one rank; no other card changes.

A column of 5 cards sorted by rank: Login bug 6, Dark mode c, Search i, Export o, Icons u. Longest rank: 1 characters. Rows written so far: 0.cardrankLogin bug"6"Dark mode"c"Search"i"Export"o"Icons"u"longest: 1 charrebalance at 6rows written: 0sorted as stringsties broken by id
Pick a card and move it. Each move writes one rank; no other card changes.

Check your understanding · Exercise 8

Why does the move request send beforeId and afterId instead of the new integer index or a client-computed rank? Select all that apply.

Optimistic moves and rollback

When the user lets go, the card must land at once; waiting 300 ms for the server would make it jump back and then forward again, or hang in the air. So the move is optimistic: apply it locally first, save in the background, and repair the rare failure. The optimistic-updates lesson covers the general pattern, and a board adds three twists: what to roll back to, several moves in flight, and explaining failures.

Sequence of an optimistic move. The user drops a card on the board. The board calls onMove for card 42 after card 7. The moves service applies the move to the store and marks it pending, and the store sends new props to the board. The moves service sends PATCH move to the server, which replies 200 with rank ik, and the service confirms the rank in the store. On the failure path, the server replies 422 WIP limit, the service rolls back card 42 in the store, and the board animates the card back.UserBoardStoreMovesServerdroponMove 42apply, pendingnew propsPATCH move200 rank ikconfirm rankif it fails422 WIP limitroll back 42animate back
The screen changes before the network call starts, and a failure undoes only this card. Every other change that arrived in the meantime stays.

The steps of one move

  1. The board fires onMove with source, destination, and neighbours, and the app's move handler creates a mutation id with crypto.randomUUID().
  2. The handler updates the store: card 42 gets a local rank from rankBetween and its new columnId, and is marked pending with the mutation id. The board re-renders from props, so the drop animation lands in the new slot.
  3. The API client sends PATCH /boards/b1/cards/42/position with the destination, the neighbours, the mutation id as an idempotency key, and the card's version as the client last saw it.
  4. On success the server returns the authoritative rank and a new version. The store replaces the local rank (usually the same order, sometimes a different string) and clears pending.
  5. The server also broadcasts card.moved to everyone on the board, including the sender, which recognises its own mutation id and ignores the echo.

Roll back the move, not the board

The naive rollback restores a snapshot of the whole board taken before the move. On a shared board that is wrong: in the 400 ms before the failure, two remote moves may have arrived, and the snapshot wipes them out. Roll back only this card, back to its origin column and old rank, and leave every other card as it is now; one move touched one card, so the inverse is equally small.

FailureWhat the client doesWhat the user sees
Network error or timeoutRetry up to 3 times with backoff (about 1 s, 2 s, 4 s plus jitter); the idempotency key makes retries safeCard stays in place with a small saving indicator; only on final failure: rollback and a toast with Retry
409 Conflict: someone moved the card to another column firstApply the current state from the response body, then explain (next chapter)The card moves to where the other person put it, with a short note
422: a hard WIP limit or another rule refused itRoll back the cardCard animates back; a toast and a live announcement say why: In progress allows 3 cards
403 Forbidden: no permission to moveRoll back and disable drag for this boardCard animates back; the board shows a read-only notice

Several moves in flight

Fast users drag card 42 to Review and then, 200 ms later, to Done, before the first request finishes. If both requests run at once, they can arrive out of order and leave the card in Review. So keep a small queue per card: the second move waits for the first to settle, while moves of other cards run in parallel.

If a later move of the same card is queued while an earlier one is still waiting to send, merge them and send only the final destination. If the first move fails after the second was applied, roll back to the state before the first, because the second was built on top of it, and tell the user once.

Check your understanding · Exercise 9

A user's move of card 42 fails with a network error after all retries. Meanwhile, a remote event moved card 17 into the same column. What should the rollback do? Select all that apply.

Other people moving cards at the same time

A team board during stand-up has ten people looking and two or three moving cards. Without live updates, each board drifts from the truth and people drag cards based on a stale picture, so the board listens to a stream of change events, usually over a WebSocket (see the websockets lesson and the slack guide for reconnects and catching up after a gap).

ts
type BoardEvent =
  | { type: "card.moved"; seq: number; cardId: string; columnId: string; laneId?: string;
      rank: string; version: number; actorId: string; mutationId?: string }
  | { type: "card.created" | "card.updated"; seq: number; card: Card }
  | { type: "card.deleted"; seq: number; cardId: string }
  | { type: "ranks.rebalanced"; seq: number; columnId: string; ranks: Record<string, string> }
  | { type: "presence.dragging"; actorId: string; cardId: string | null; over?: string };
Board events. seq increases by one per board, so a client can spot a missed event and refetch.

Applying a remote move is a small store update: set the card's column and rank, bump its version, re-sort that column. If an event's seq skips a number, the client missed something (a reconnect, a dropped message), and the safe answer is to refetch the board's card positions, a cheap request of only ids, columns, ranks, and versions.

Two people move the same card

Asha drags card 42 from To do to In progress. At the same moment Ben drags it from To do to Blocked. Both clients applied their move optimistically. The server receives Asha's request first, then Ben's.

Decision What does the server do with the second move?

Last writer wins, silently

Apply both in arrival order, so Ben's move is the final state, and broadcast both.

Pros

  • Simplest; no new error paths, and every client converges.

Cons

  • Asha sees her move undone with no explanation.
  • A column change usually means a status change, so a silent override can hide real disagreement.

Version check, reject the stale move

Each move carries the card's version the client saw.

If the server's version is newer, return 409 with the current card; the client rolls back and explains.

Pros

  • Nobody's change is overwritten without them knowing, and the loser can retry deliberately: Move anyway.

Cons

  • Rejects harmless cases too: if someone only reordered card 42 inside its column a moment ago, the version changed and a later move fails for no real reason.

Version check only when the column changed

Reorders inside a column use last writer wins.

A move whose source column no longer matches the server's column for that card gets a 409.

Pros

  • Frequent, low-stakes reorders never fail, and status changes are never silently overwritten.

Cons

  • Two rules to explain and test.

What we pick: Use the third. Ben's request says the card was in To do, but the server has it in In progress, so Ben gets a 409 with the current state. His board moves the card to In progress and says: Asha moved this card to In progress a moment ago, with a Move to Blocked anyway action. Both boards converge, and nobody's decision vanishes silently.

Keeping a drag stable while updates arrive

Remote events do not wait for a local drag to finish. While Asha holds card 42 between cards 7 and 9 in In progress, an event inserts card 30 above card 7. If the board re-sorts while the engine still holds rectangles measured at drag start, the placeholder is off by one card and Asha drops in the wrong place. There are two approaches, plus special cases for the dragged card.

  • Buffer remote events during a drag and apply them on drop. Simple and stable, but a long drag (auto-scrolling a big backlog) can hide a minute of changes, and the drop is computed against a stale board.
  • Apply them and re-measure. Apply events at once, and after the render (in a layout effect) re-measure only the changed columns. Keep the current target as neighbour ids ("after card 7") and recompute its index from them, so the placeholder stays next to the same cards.
  • Special cases for the dragged card. If it is deleted remotely, cancel the drag and announce why. If it is moved remotely, keep the drag going; the drop goes through the conflict rule above.

We pick apply and re-measure, with the special cases. Animate remote moves of other cards with a short slide (the FLIP technique: measure first and last positions, then animate the difference with a transform), except for bulk changes like a rebalance or when reduced motion is on. Optionally, show where others are dragging with throttled presence messages (10 to 20 per second, as in the presence-and-cursors lesson); a faint outline on the card Ben holds often prevents the conflict.

Sequence of a remote move during a drag. Asha lifts card 42 on her board. Ben's board sends the server a move of card 30 above card 7. The server sends card.moved 30 to Asha's board, which re-measures that column and keeps the slot after card 7. Asha drops, her board sends PATCH after 7, the server replies 200 with the rank, and the server sends card.moved 42 to Ben's board.AshaAsha's boardServerBen's boardlift card 42move 30 above 7card.moved 30slot still after 7dropPATCH after 7200 rankcard.moved 42
The drag keeps its target as neighbour ids, so a card inserted above it shifts the index but not the meaning. Only the changed column is measured again.

Check your understanding · Exercise 10

While a user drags a card, a remote event inserts a new card above the drop target in the same column. Which designs keep the drop correct? Select all that apply.

Keyboard drag, announcements, and the Move to menu

A pointer-only board shuts out keyboard users, screen reader users, switch users, and people who cannot hold a button down while moving. It also fails WCAG 2.2 success criterion 2.5.7 Dragging Movements (level AA), which asks for a single-pointer way to do anything dragging does. So the board needs two more paths to the same move event: a keyboard drag and a menu.

Structure and focus first

Give the board real structure before adding drag keys. Each column is a section labelled by its heading ("In progress, 3 of 3 cards, limit 3"), its cards are a ul of li, and each card has one focusable element, the card itself or its title link.

With 2,000 cards, putting every card in the Tab order makes the board a wall of Tab presses, so use a roving tabindex, where only the active card has tabindex="0" (see the keyboard-navigation lesson): the board is one Tab stop. Inside it, Up and Down move between cards in a column, Left and Right move between columns, Home and End jump to the first and last card, and Enter opens the focused card.

Keyboard drag

KeyWhile not draggingWhile dragging
SpaceLift the focused cardDrop it at the current slot
Up / DownFocus the previous or next cardMove one slot up or down in the column
Left / RightFocus the nearest card in the next columnMove to the next column, keeping the same index if possible (clamped to the column's length)
EscapeClose an open card menu, if anyCancel: the card goes back to its origin
EnterOpen the cardDrop (same as Space)

The WAI-ARIA Authoring Practices Guide has no drag-and-drop pattern, and the old aria-grabbed and aria-dropeffect attributes were deprecated in ARIA 1.1, so no official key scheme exists. This one is the convention react-beautiful-dnd popularised and dnd-kit's keyboard sensor follows: Space to pick up, arrows to move, Space to drop, Escape to cancel. dnd-kit also lifts on Enter and discourages removing it; where Enter opens the card, either give the card a drag handle button that lifts on both keys, or keep Enter for opening and say so in the instructions.

A keyboard drag skips the pending state and moves the target slot directly, with no geometry: Up means index minus one, Right means the next column. That is why the engine's target is a slot, not a pointer position. Point each card's aria-describedby at one hidden instruction text, such as: Press Space to pick up, arrow keys to move, Space to drop, Escape to cancel.

Focus must survive the move. When a card moves to another column, React may unmount the element in the old list and mount a new one, and focus falls to body. Keep a ref to the moved card's id and, after the render, call focus() on its new element (the focus-management lesson covers this restore pattern); a screen reader user who loses focus after every move cannot use the board at all.

Live announcements

Screen readers do not see the placeholder move, so every step is spoken through a visually hidden live region (aria-live), as in the toast-notifications and data-table guides. Say the card's name (from getCardLabel), the column, and the position as "x of y". Avoid ids: dnd-kit's default messages mention draggable item ids, and its docs advise customising them for your app.

MomentAnnouncement
LiftPicked up Fix login bug. In To do, position 2 of 5.
Move inside a columnPosition 1 of 5 in To do.
Move to another columnMoved to In progress, position 3 of 3.
Over a full column with a hard limitIn progress is at its limit of 3. Cannot drop here.
DropDropped Fix login bug in In progress, position 3 of 3.
CancelMove cancelled. Fix login bug returned to To do, position 2 of 5.
Save failed laterCould not save the move. Fix login bug is back in To do.
Sequence of a keyboard move. The user presses Space on a focused card; the card sends LIFT_KEY 42 to the engine, which tells the live region: Picked up. The user presses Right Arrow; the card sends the engine the slot Doing, 3, and the live region says Doing, 3 of 3. The user presses Space; the card sends drop; the engine calls onMove with source keyboard on the store; the store re-renders the card in its new column and focus is restored on it; the engine tells the live region Dropped in Doing.UserCardEngineLive regionStoreSpaceLIFT_KEY 42Picked upRight Arrowslot: Doing, 3Doing, 3 of 3SpacedroponMovefocus()Dropped
No geometry runs: the keys change the slot directly. The two steps people forget are the last two: put focus back on the card after it remounts in its new column, and say where it landed.

The Move to menu

Keyboard drag is quick for experts but hard to discover, and it is not a single-pointer action. So every card also gets an actions button with a Move to item: a small dialog with a column select, a lane select if there are swimlanes, and a position (Top, Bottom, or After a named card). Confirming fires the same onMove with source: "menu"; Pragmatic drag and drop's guidance uses this kind of action menu as its accessible path. The menu also helps on phones and on filtered boards, as the next chapter shows.

Check your understanding · Exercise 11

A screen reader user moves a card from To do to Done with the keyboard. After the drop, they hear nothing and focus is on the page body. What is missing? Select all that apply.

Filters, WIP limits, and small screens

Three features look like small extras, yet each changes what a drop means, and they are where hand-built boards most often misplace cards.

Dragging on a filtered or sorted board

Filter the board to cards assigned to Asha. In To do she sees cards A and B, but the full column is A, x, y, B, with x and y hidden. She drags card C between A and B: should it go right after A, right before B, or somewhere between x and y? She cannot see x and y, so any answer must be predictable.

Decision How does a drop on a filtered or sorted view map to the real order?

Disable drag while filtered or sorted

Set dragDisabled with a reason while any filter or sort is active; the Move to menu can still change the column.

Pros

  • No surprising placements, and simple to explain.

Cons

  • Filtering by assignee is how many people use a board every day; they lose drag.

Map to the visible neighbour above

Send beforeId = the visible card above the drop (A) and afterId = the next card after A in the full order (x).

The card lands right after A, before any hidden cards.

Pros

  • Predictable: it goes right below the card you dropped it under, through the same move API.

Cons

  • Hidden cards between A and B end up below C, which the user did not see happen.

Sorted views: allow column moves only

When the view is sorted by a field (priority, due date), order inside a column comes from the sort, not from ranks.

Allow moves to another column, which change status, and disable reordering inside a column.

Pros

  • Honest: dragging up in a priority-sorted list cannot change the position, so do not pretend it can.

Cons

  • Needs a clear message: Reordering is off while sorted by priority.

What we pick: Use the visible-neighbour rule for filters and column-moves-only for sorts. The first rule needs the full order, so the host app passes the unfiltered column order alongside the visible cards, or the server resolves "right after card A" itself. A card that stops matching the filter after a move (say it changes lane and the filter is on lane) should stay visible, dimmed, until the filter changes, rather than vanish from under the user's hand.

WIP limits and invalid drops

  • Soft limits show the count against the limit in the column header (4 / 3) with a warning colour plus text, never colour alone. The drop is allowed.
  • Hard limits run through canDrop. Over a full column, the column shows a not-allowed state and its reason, no placeholder opens, and the announcement says why; releasing there is a cancel.
  • Moves inside one column never break a limit, so canDrop should not block a reorder in a full column. A team that uses column minimums checks moves out of a column with the same mechanism.
  • The server enforces too. Two people can each drop a third card into a column with a limit of 3 at the same moment, both having seen 2 cards. The server rejects the second with 422, and that client rolls back as in the optimistic chapter.

Phones: paging columns and long press

On a 390 px wide phone, five 280 px columns do not fit, and a zoomed-out board is unreadable. Show one column at a time, about 85 percent of the screen wide so the next one peeks in, and let the user swipe between them with CSS scroll snap: scroll-snap-type: x mandatory on the board and scroll-snap-align: start on each column. A row of column chips above shows where you are and jumps to a column.

Wireframe of the board on a phone. A row of chips at the top names the columns To do, Doing, and Done, with Doing selected. Below, the Doing column fills about 85 percent of the width, and the edge of the Done column peeks in on the right. A lifted card in Doing is raised after a long press. Notes outside say the chips jump to a column, a long press lifts a card, the next column peeks in, and the board uses scroll snap on the x axis, turned off during a drag.BoardTo doDoingDoneAPI docsCacheSearchPerf fixchips jumpto a columnlong presslifts a cardnext columnpeeks inscroll-snap: xoff in a drag
One column per screen with the next one peeking in tells people they can swipe. During a drag, switch scroll-snap-type off so snapping does not fight auto-scroll, and page one column at a time when the card is held at the edge.
  • Lift with a long press (see the lifecycle section), so vertical swipes scroll the column and horizontal swipes page between columns.
  • Page by edge hold during a drag. Holding the card near the right edge for about half a second scrolls one column over; continuous auto-scroll overshoots on a narrow screen.
  • Turn snapping off while dragging. Mandatory snapping fights the auto-scroll loop that sets scrollLeft, so switch to scroll-snap-type: none at lift and back on drop, which settles the board on a column.
  • Offer Move to. Dragging a card across three columns on a phone is slow; the menu, shown as a bottom sheet, is often the better tool.

Check your understanding · Exercise 12

The board is filtered to Asha's cards. To do holds A, x, y, B in full order, with x and y hidden. Asha drops card C between A and B. Using the visible-neighbour rule, which statements are true? Select all that apply.

Architecture, data model, and server API

With the decisions made, we can draw the boxes. The main boundary separates the board package (used by every team) from what the host app owns (data, network, rules); inside the package, a second one separates the headless drag engine, which knows nothing about React, from the components that draw columns and cards.

Component architecture in three bands. Board package: KanbanBoard takes props and emits onMove, the drag engine runs the lifecycle and geometry, and the overlay and accessibility part holds the portal and the live region. Host app: the board store holds normalized cards and ranks and passes props to KanbanBoard; the move service receives onMove, applies moves optimistically to the store, sends PATCH to the move API, and receives remote moves from the realtime client. Server: the move API and the event stream, which feeds the realtime client over a WebSocket.Board packageKanbanBoardprops in, onMove outDrag enginelifecycle, geometryOverlay, a11yportal, live regionHost appBoard storenormalized, ranksMove serviceoptimistic, rollbackRealtime clientWebSocket, seqpropsonMoveServerMove APIPATCH positionEvent streamcard.moved, seqPATCHevents
The package knows nothing about the network: data comes in as props and leaves as one move event. The host app owns the store, saving, and live events, because those differ per product.
PartLives inResponsibility
KanbanBoardPackageTakes columns, cards, lanes, and callbacks; provides the drag context; renders columns.
Drag enginePackage, plain TypeScriptThe lifecycle reducer, input sensors (pointer, keyboard), the current slot. No DOM rendering, so it is unit-testable.
Geometry cachePackageMeasures column and card rectangles at lift, shifts them on scroll, re-measures columns that change, reads virtualizer offsets.
Collision resolverPackageTurns pointer position plus geometry into a slot with the two-step rule; asks canDrop.
Auto-scrollerPackageThe frame loop for board and column edges; pauses snapping on mobile.
Column and VirtualColumnPackageRender the header (count, WIP state) and the card list, plain or virtualized; keep the dragged card pinned.
CardSlotPackageThe wrapper around the app's renderCard: events, focus, aria-describedby, the shift transform.
DragOverlayPackageThe portal copy under the pointer, moved imperatively each frame.
Announcer and MoveMenuPackageThe live region with message templates; the accessible Move to dialog.
Board storeHost appNormalized cards and column orders by rank; pending marks; applies local and remote changes.
Move serviceHost appOptimistic apply, per-card queue, retries, rollback, conflict handling.
Realtime clientHost appWebSocket connection, sequence gap detection, presence messages.

Why put the store and sync in the host app? Because they differ per product: one team's cards come from a REST API with React Query, another's from GraphQL subscriptions, a third lives in a CRDT document. The board only needs props and a move callback, though the package can ship helpers most apps want, like rankBetween and an applyMove for normalized column orders, as separate imports.

Why keep the engine headless? Tests: the reducer and collision math run in Node without a browser. Reuse: the same engine can power a list reorder or a column reorder (columns are sortable too, along the horizontal axis). Performance: an engine outside React runs its frame loop without triggering renders and publishes only slot changes to React through a tiny store.

How data flows

Data flow for the board. Load: the board API reads the database and sends board JSON to the board store, which passes props made of ids to KanbanBoard; the drag engine feeds slots to KanbanBoard locally. Write: KanbanBoard emits onMove to the move service, which applies it to the board store and sends PATCH to the board API, which writes one row. Live: the database change goes out as card.moved on the event stream, which reaches the realtime client and then the board store.Board APIGET, PATCHDBEvent streamWebSocket1 rowcard.movedMove serviceoptimisticBoard storecards by id, ranksRealtime clientseq, dedupeboard JSONPATCHeventsKanbanBoardcolumns, cardsDrag engineper frame, localprops (ids)onMove
Three paths share one store: the load fills it, local moves change it first and the server second, and remote events change it from the other side. The drag engine's per-frame data never leaves the client.

Follow one load and one move. On load, the app fetches the board (columns, lanes, and cards with ranks) and opens the WebSocket, sending the last seq it saw; the store normalizes cards into a map, sorts each column's ids by rank, and the board renders from those props.

On a drop, the engine emits a slot, the board turns it into a MoveEvent, and the move service applies it and sends the request. The server writes the rank and broadcasts card.moved; other clients apply it and re-render one column, and the sender ignores its own echo.

Think first

The product also wants users to drag whole columns to reorder them. What can you reuse, and what is different?

Data model: where each piece of state lives

The client holds five kinds of data, and each belongs in a different place. Mixing them, for example putting the drag state in the server cache or the filter in component state, is how boards end up re-rendering too much or losing the filter on reload.

DataWhere it livesWhy there
Cards, columns, lanes, ranks, versionsNormalized store in the host app (server cache)Shared with other screens (card detail, search); updated by moves and remote events.
Pending moves and their mutation idsMove service, next to the storeNeeded for rollback, echo suppression, and the per-card queue.
Filters, sort, swimlane groupingURL query (?assignee=asha&lanes=epic)Shareable as a link and survives reload.
The drag in progress, geometry, slotInside the board package, outside React stateChanges up to 60 times per second; nobody else needs it.
Collapsed columns, column widthslocalStorage, per user and boardPersonal view preference, not shared data.
ts
type Card = {
  id: string;
  boardId: string;
  columnId: string;
  laneKey?: string; // e.g. the assignee id when lanes are by assignee
  rank: string; // fractional key, compared as plain strings
  version: number; // bumps on every change to the card
  title: string;
  assigneeIds: string[];
  labelIds: string[];
};

type Column = { id: string; title: string; rank: string; wipLimit?: number; wipMode?: "soft" | "hard" };

type BoardState = {
  cards: Record<string, Card>;
  columns: Record<string, Column>;
  columnOrder: string[]; // column ids sorted by rank
  cardOrder: Record<string, string[]>; // columnId -> card ids sorted by rank
  pending: Record<string, { mutationId: string; previous: Pick<Card, "columnId" | "rank" | "laneKey"> }>;
  lastSeq: number;
};
The client-side model. Column order is derived from ranks, never stored as a separate array on the server.

cardOrder is derived data: it is rebuilt for one column whenever a card in that column changes rank or column. Because it holds ids, a move changes two short arrays and one card object, and every other card keeps the same object, so memoized cards skip re-rendering. The pending entry stores exactly what a rollback needs: the card's previous column, rank, and lane.

Data model sketch. A Card has id, columnId, laneKey, rank, version, and title, and lives on the server and in the store. A Column has id, title, rank, and wipLimit. The board store holds cards by id, cardOrder per column derived from ranks, pending moves, and lastSeq. A card's columnId points to its column, and the store holds both. Below, notes say filters live in the URL, the drag state lives inside the board package, and collapsed columns live in localStorage.CardidcolumnIdlaneKey?rankversiontitleColumnidtitlerankwipLimit?Board storecards[id]cardOrder[col]pending[id]lastSeqcolumnId points to a columnURL: filters,lanes, sortpackage: dragstate, geometrylocalStorage:collapsed columns
Order is not stored as an array on the server: it is each card's rank, and the client derives cardOrder per column. A move changes one card and two short id lists.

Endpoints

http
GET /boards/b1?cardsPerColumn=100
200 OK
{
  "board": { "id": "b1", "title": "Web team", "seq": 18452 },
  "columns": [{ "id": "todo", "title": "To do", "rank": "i", "wipLimit": null,
                "cardCount": 640, "nextCursor": "ir" }],
  "cards": [{ "id": "42", "columnId": "todo", "rank": "ii", "version": 7,
              "title": "Fix login bug", "assigneeIds": ["u3"], "labelIds": [] }]
}

GET /boards/b1/columns/todo/cards?after=ir&limit=100
Load: columns, lanes, and the first page of each column. Big columns page by rank.
http
PATCH /boards/b1/cards/42/position
Idempotency-Key: 0b6e2c1e-3c3e-4a51-9a52-6f0d7d1a9e10
{
  "columnId": "doing", "laneKey": "u3",
  "beforeId": "7", "afterId": "9",
  "expected": { "columnId": "todo", "version": 7 }
}

200 OK
{ "id": "42", "columnId": "doing", "rank": "ik", "version": 8, "seq": 18453 }

409 Conflict
{ "error": { "code": "CARD_MOVED", "message": "Card was moved by Asha",
  "current": { "columnId": "review", "rank": "c", "version": 8 } } }
Move: one card, its destination, and its new neighbours. The idempotency key makes retries safe.

Three details. beforeId and afterId can be null for the ends of a column, and the request may instead say "position": "end", which the Move to menu's Bottom option uses when the column is only partly loaded. Errors share one shape with a stable code, so the move service can switch on CARD_MOVED, WIP_LIMIT, and FORBIDDEN without parsing messages. And the response carries the board seq, so the client knows which event already reflects this change.

Check your understanding · Exercise 13

Which pieces of state belong outside the shared board store? Select all that apply.

Load time, RTL, observability, and testing

A board also has to open quickly, work in right-to-left languages, and tell its owners when drags go wrong in the field. Interviewers often pick these for the last ten minutes.

Opening the board quickly

  • Load card summaries, not cards. The board needs a title, labels, assignee ids, and a rank, roughly 200 to 400 bytes per card as JSON. 2,000 cards is under 1 MB uncompressed and much less with gzip or Brotli, but there is no reason to send it all at once: send the first 100 cards per column and page big columns by rank cursor as the user scrolls.
  • Render the frame first. Column headers with counts come from the first response, so the layout is stable before cards arrive and Cumulative Layout Shift stays low. Give card skeletons a fixed height near the median card height.
  • Split code by moment. The drag engine is needed at the first pointerdown, not at first paint, and the Move to dialog and card detail only when opened. Load the engine right after the board paints (or on first hover of a card) so the first drag never waits on a download.
  • Watch INP on drop. The drop handler runs the store update, the re-sort of two columns, and the request; keep it small, and move non-urgent work (analytics, saving view preferences) to after the next paint.

Right-to-left boards

In Arabic or Hebrew, the first column sits on the right. With flexbox and dir="rtl" the layout flips for free, but the engine must flip too: arrow keys should follow the screen, so Right Arrow moves to the column on the right, the previous one in reading order. In current browsers scrollLeft is 0 at the start of an RTL container and negative toward its end, so write the engine in terms of start and end edges and convert to screen directions in one place.

Knowing when drags go wrong

Drag bugs rarely throw errors; the card just lands in the wrong place or the frame rate drops on some phones. Log the outcomes that matter as events with the input source: drag started, dropped, cancelled (and why), move failed (with error code), conflict shown, rollback done. Track the time from drop to server confirmation, the conflict rate per board, and the share of drops that land back where they started, a hint that targets feel wrong.

For smoothness, sample the Long Animation Frames API during drags: a PerformanceObserver with type long-animation-frame, available in Chromium browsers, reports frames over 50 ms together with the scripts that caused them.

Testing

  • Unit tests, no browser: the lifecycle reducer (every transition, including a remote delete mid-drag), the collision math with fixed rectangles, rankBetween with randomized inserts, and the move service's rollback and queue rules.
  • Browser tests (Playwright): drag with mouse.down, several mouse.move steps, then mouse.up, because one jump skips the movement threshold and auto-scroll. Cover cross-column moves, empty columns, a full hard-limit column, and auto-scroll to a far column.
  • Keyboard and screen reader: scripted keyboard drags that assert the live region text and focus after the drop, an axe scan for roles and names, and a manual pass with VoiceOver and NVDA, whose announcement timing differs.
  • Concurrency: two browser contexts moving the same card on one board, checking that both end with the same order.

Check your understanding · Exercise 14

A Playwright test drags a card with page.mouse.down(), one page.mouse.move() straight to the target, and page.mouse.up(). The card never moves. Which explanations fit the design in this guide? Select all that apply.

Running it in the interview

Sixty minutes is not enough to say everything in this guide. The interviewer wants to see you pick the hard parts, decide with reasons, and go deep on two or three; here is a plan that fits.

MinutesWhat to doWhat to draw
0 to 6Scope: component or app, board size, devices, concurrency, WIP limits, filters. Write the numbers down.A requirements list with 2,000 cards, 50 viewers, touch and keyboard.
6 to 14The API: controlled columns and cards as ids, onMove with from, to, and neighbours, renderCard, canDrop.The props block and one MoveEvent example.
14 to 28Drag: pointer events versus native, the lifecycle machine, two-step targeting, auto-scroll, the fast loop's rules.The state machine and the board wireframe with a placeholder.
28 to 40Order and saving: fractional ranks, neighbour-based move request, optimistic apply and rollback.A rank line (A, new, B) and the move sequence with its failure path.
40 to 50Concurrency and accessibility: same-card conflicts, stable drag under remote events, keyboard drag, announcements, Move to.The remote-event sequence; the key table.
50 to 60Architecture recap and follow-ups: virtualization, filters, mobile.The component diagram with the package and app boundary.

Use the two loops to organise the hour, and when you pick something, name what you gave up: "pointer events, so we own scrolling and hit testing, and we lose dragging files from the desktop unless we add a native drop handler". That sentence earns more than a list of libraries.

Follow-up questions and strong answers

Think first

Users want to select five cards and drag them together. What changes?

Think first

How would you add undo for moves?

Think first

Would you build the drag engine or use a library?

Check your understanding · Exercise 15

You have 10 minutes left and have covered the API and drag mechanics. The interviewer asks what happens when two people move the same card. Which answer shows the most senior judgement? Pick one.

Summary

  • Split the work into a fast loop (per frame during a drag, all on the client) and a slow loop (per drop: rank, store, server, other users), and never let the fast loop wait on the slow one.
  • Make the board controlled: columns and cards come in as normalized ids, every move goes out as one onMove with source, destination, and neighbour ids, and rollback or remote updates are just new props.
  • Drive drags with pointer events and a real state machine (idle, pending with a distance or long-press threshold, dragging, dropping, cancelling), and treat keyboard and menu moves as other inputs that produce the same slot.
  • Find targets with arithmetic on rectangles measured once: the column by the pointer, the slot by card midpoints with a little hysteresis, empty columns as index 0, and re-measure only on scroll or when a column changes.
  • Keep frames cheap: move the overlay with transform outside React, publish only slot changes, re-render only the cards that shift, and pin the dragged card in virtualized columns.
  • Store order as string fractional ranks so each move writes one row; send neighbour ids, not indexes; let the server compute the rank; rebalance long ranks in the background.
  • Apply moves optimistically, roll back only the moved card, queue moves per card, and resolve same-card conflicts with last writer wins for reorders and a 409 for column changes.
  • Accessibility is a full second path: roving focus, Space to lift, arrows to move, Space to drop, Escape to cancel, spoken positions, focus restored after the move, and a Move to menu for single-pointer users.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next