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.
| Question | Assumed answer | What changes if the answer is different |
|---|---|---|
| Is this a reusable component or one screen in one app? | A reusable component for several product teams | One 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 column | Under 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 readers | On 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 board | Single 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 cases | If 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 hard | Hard 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 optional | A 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.
- 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
pointermoveand keep it tiny. - 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.
- 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.
- 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.
- 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?
A drag fires pointermove about 60 times per second, more on high refresh rate screens. That is hundreds of writes per drag, most for positions the user only passed through, each broadcast to 50 viewers.
It also mixes the loops: the server would see half-finished moves, and a cancelled drag would need undoing there. For live previews, send a throttled presence message ("Asha is dragging card 42 over Review") that is never saved, as in the presence-and-cursors lesson, and save only the drop.
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?
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.
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 };
};
Five details in that shape matter.
fromandtocarry indexes, and the event also carriesbeforeIdandafterId. 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).renderCardis 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.canDropvetoes a move before the drop, with a reason the board can show and announce. WIP limits and permission rules plug in here.getCardLabelexists because the board must speak about cards to screen readers but does not know what a card is.sourcelets 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 withsetDragImage). 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
pointercanceland stops sending pointer and mouse events; you only getdraganddragover, 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()indragoveror the drop is refused,dragenteranddragleavefire for every child element you cross, and defaults (a dropped link opens a page) need suppressing.
Decision Which engine drives the drag?
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.
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)onpointerdown, sopointermoveandpointerupkeep 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 changingtouch-actionafterpointerdownhas no effect. Puttouch-action: noneon a drag handle if there is one. If the whole card is draggable, keep scrolling enabled, lift only after a long press, then callpreventDefault()in a non-passivetouchmovelistener, 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.
- idle: nothing is happening. A
pointerdownon 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.
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" };
Edges the machine must handle
- The click after a drag. Browsers fire
clickafterpointerup, 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: noneon 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
contextmenuon cards while pending, and set-webkit-touch-callout: noneon 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: reducematches, 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?
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.
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.
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
}
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.
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
scrollshift the cached rectangles by the delta and run target detection again, even though nopointermovefired. 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.
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?
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.
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 animatetop,left,margin, orheightduring a drag. Promote only the overlay withwill-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'screatePortal), so a column'soverflow: autodoes 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.
- 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.
- 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.
- 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 itrangeExtractor); react-beautiful-dnd solved the same problem with a separate clone renderer for virtual lists.
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?
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.
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.
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
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.
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.
The steps of one move
- The board fires
onMovewith source, destination, and neighbours, and the app's move handler creates a mutation id withcrypto.randomUUID(). - The handler updates the store: card 42 gets a local rank from
rankBetweenand its newcolumnId, and is markedpendingwith the mutation id. The board re-renders from props, so the drop animation lands in the new slot. - The API client sends
PATCH /boards/b1/cards/42/positionwith the destination, the neighbours, the mutation id as an idempotency key, and the card'sversionas the client last saw it. - 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. - The server also broadcasts
card.movedto 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.
| Failure | What the client does | What the user sees |
|---|---|---|
| Network error or timeout | Retry up to 3 times with backoff (about 1 s, 2 s, 4 s plus jitter); the idempotency key makes retries safe | Card 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 first | Apply 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 it | Roll back the card | Card animates back; a toast and a live announcement say why: In progress allows 3 cards |
403 Forbidden: no permission to move | Roll back and disable drag for this board | Card 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).
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 };
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?
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.
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
| Key | While not dragging | While dragging |
|---|---|---|
| Space | Lift the focused card | Drop it at the current slot |
| Up / Down | Focus the previous or next card | Move one slot up or down in the column |
| Left / Right | Focus the nearest card in the next column | Move to the next column, keeping the same index if possible (clamped to the column's length) |
| Escape | Close an open card menu, if any | Cancel: the card goes back to its origin |
| Enter | Open the card | Drop (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.
| Moment | Announcement |
|---|---|
| Lift | Picked up Fix login bug. In To do, position 2 of 5. |
| Move inside a column | Position 1 of 5 in To do. |
| Move to another column | Moved to In progress, position 3 of 3. |
| Over a full column with a hard limit | In progress is at its limit of 3. Cannot drop here. |
| Drop | Dropped Fix login bug in In progress, position 3 of 3. |
| Cancel | Move cancelled. Fix login bug returned to To do, position 2 of 5. |
| Save failed later | Could not save the move. Fix login bug is back in To do. |
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?
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
canDropshould 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.
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 toscroll-snap-type: noneat 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.
| Part | Lives in | Responsibility |
|---|---|---|
KanbanBoard | Package | Takes columns, cards, lanes, and callbacks; provides the drag context; renders columns. |
| Drag engine | Package, plain TypeScript | The lifecycle reducer, input sensors (pointer, keyboard), the current slot. No DOM rendering, so it is unit-testable. |
| Geometry cache | Package | Measures column and card rectangles at lift, shifts them on scroll, re-measures columns that change, reads virtualizer offsets. |
| Collision resolver | Package | Turns pointer position plus geometry into a slot with the two-step rule; asks canDrop. |
| Auto-scroller | Package | The frame loop for board and column edges; pauses snapping on mobile. |
Column and VirtualColumn | Package | Render the header (count, WIP state) and the card list, plain or virtualized; keep the dragged card pinned. |
CardSlot | Package | The wrapper around the app's renderCard: events, focus, aria-describedby, the shift transform. |
DragOverlay | Package | The portal copy under the pointer, moved imperatively each frame. |
Announcer and MoveMenu | Package | The live region with message templates; the accessible Move to dialog. |
| Board store | Host app | Normalized cards and column orders by rank; pending marks; applies local and remote changes. |
| Move service | Host app | Optimistic apply, per-card queue, retries, rollback, conflict handling. |
| Realtime client | Host app | WebSocket 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
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?
Reuse the engine, the lifecycle, the overlay, ranks for column order, the optimistic move service, and the announcer: a column is a sortable item on the horizontal axis.
Differences: the collision rule is one-dimensional (midpoints along x), the drag handle is the column header only (so dragging a card does not grab its column), the overlay should be a compact preview (header plus card count), and cards inside the lifted column are not drop targets.
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.
| Data | Where it lives | Why there |
|---|---|---|
| Cards, columns, lanes, ranks, versions | Normalized 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 ids | Move service, next to the store | Needed for rollback, echo suppression, and the per-card queue. |
| Filters, sort, swimlane grouping | URL query (?assignee=asha&lanes=epic) | Shareable as a link and survives reload. |
| The drag in progress, geometry, slot | Inside the board package, outside React state | Changes up to 60 times per second; nobody else needs it. |
| Collapsed columns, column widths | localStorage, per user and board | Personal view preference, not shared data. |
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;
};
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.
rank, and the client derives cardOrder per column. A move changes one card and two short id lists.Endpoints
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
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 } } }
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,
rankBetweenwith randomized inserts, and the move service's rollback and queue rules. - Browser tests (Playwright): drag with
mouse.down, severalmouse.movesteps, thenmouse.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.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 6 | Scope: 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 14 | The API: controlled columns and cards as ids, onMove with from, to, and neighbours, renderCard, canDrop. | The props block and one MoveEvent example. |
| 14 to 28 | Drag: 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 40 | Order 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 50 | Concurrency and accessibility: same-card conflicts, stable drag under remote events, keyboard drag, announcements, Move to. | The remote-event sequence; the key table. |
| 50 to 60 | Architecture 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?
Selection becomes board state (a set of card ids, with Shift and Cmd or Ctrl click, and Space with a modifier on the keyboard). The overlay shows the first card with a count badge, and the placeholder takes the height of one card or a compact stack, not five full cards, or the column jumps.
On drop, the selected cards keep their relative order: generate five ranks that split the gap evenly, send one batch request, and roll back all five on failure. Announce: Dropped 5 cards in Review, positions 2 to 6 of 9.
Think first
How would you add undo for moves?
Each successful move pushes its inverse to an undo stack: card 42 back to To do after card 5 (neighbour ids, not an index or old rank, because the board may have changed). Undo sends a normal move request, so it takes the same optimistic path and conflict rules.
If someone else has moved the card since, undo should not silently override them: skip with a message or treat it as a conflict. Keep the stack per board and clear it on navigation.
Think first
Would you build the drag engine or use a library?
For a product, start from a maintained library and spend effort on the board-specific parts: the API, ranks, sync, announcements, and virtualization. dnd-kit gives pointer and keyboard sensors, collision functions, and a live overlay; Pragmatic drag and drop gives a tiny native-based core with auto-scroll and drop indicator packages. Do not start new work on react-beautiful-dnd, which Atlassian has deprecated and archived; teams tied to its API can move to @hello-pangea/dnd, a community-maintained fork with the same API.
In the interview, still explain the engine as if you were building it: the interviewer is testing whether you understand thresholds, targeting, and frame budgets, not whether you can name a package.
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
onMovewith 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
transformoutside 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
409for 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.
References
- Pointer events (MDN) Pointer capture,
pointercancel, andtouch-action, the base of a pointer-driven drag engine. - HTML Drag and Drop API (MDN) The native events,
DataTransfer, and drag images, to compare against a pointer engine. - Understanding SC 2.5.7: Dragging Movements (W3C) Why every drag needs a single-pointer alternative such as a Move to menu.
- Pragmatic drag and drop: about (Atlassian) Atlassian's native-based drag and drop library, with auto-scroll and accessibility guidance.
- react-beautiful-dnd (GitHub, archived) The deprecation notice and the library's ideas on natural movement and keyboard dragging.
- dnd-kit (GitHub) Sensors with activation constraints, collision detection functions, and keyboard support with live announcements.
- Collision detection algorithms (dnd-kit docs) Pointer within, closest center, and closest corners, and why Kanban layouts trip up closest center.
- Managing LexoRank (Atlassian docs) How Jira stores ranks as strings and when it rebalances them.
- Realtime editing of ordered sequences (Figma blog) Fractional indexing for ordering under concurrent edits.
- Interaction to Next Paint (web.dev) The responsiveness metric a drop handler must respect.
- CSS scroll snap (MDN) Column paging on phones.
Concepts behind it
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.