Design a drag-and-drop reorderable list
A reusable sortable list where people change the order of items by dragging, by keyboard, or with move buttons. The hard parts are finding the right drop position with items of different heights, moving the other items smoothly without fighting the DOM, and saving the new order safely.
The question
Design a list component where people can reorder items by dragging them, like reordering songs in a Spotify playlist, tasks in Todoist, or fields in a form builder. Other teams will reuse it across the product.
Walk me through how the drop position is found, how the other items move out of the way, how it works on touch and with a keyboard, what the component API looks like, and how the new order gets saved.
Real-life examples: Spotify playlist order, Todoist task priority, Trello checklist items, Form builder field order, Photo gallery order, dnd-kit Sortable, SortableJS, Pragmatic drag and drop, React Aria useDragAndDrop
Requirements: scoping a sortable list
A sortable list is one column of items that people can put in a new order. You grab a song, move it up three places, and let go. It sounds like a small widget, but it is the core of many products: a playlist, a task list sorted by priority, the order of fields in a form, the order of photos in a listing, the steps of a checklist.
This guide designs the reusable component. The Kanban board guide already covers a board with many columns, so here we stay with one list and go deep on what is special about it. Start with questions; each answer below changes something real in the design.
| Question | Assumed answer | What changes if the answer is different |
|---|---|---|
| How many items in one list? | Usually 5 to 200; up to 2,000 for a long playlist | Under about 300 we render every item. At thousands we need virtualization, and the dragged item must stay mounted. |
| Do items have the same height? | No: a task with a long title wraps to 3 lines, a form field with help text is taller | Fixed heights would let us compute the drop index with one division. Variable heights mean we measure each item. |
| Vertical list only, or also rows and grids? | Vertical by default; horizontal strips and wrapping photo grids too | A grid needs insertion by row and column, not just top to bottom. |
| Do items contain inputs, links, or buttons? | Yes: form fields have inputs, tasks have checkboxes | Whole-item dragging would steal clicks and text selection, so we need a drag handle. |
| Which inputs? | Mouse, touch, pen, keyboard, screen readers | Touch means a drag fights with page scrolling. Keyboard and screen readers need a full path without a pointer. |
| Can items nest? | Optional: one or two levels, like subtasks | Nesting adds a depth to every drop, chosen by horizontal offset. |
| Who saves the order, and is the list shared? | The host app saves it; some lists are shared by a team | A private 20-item list can send the whole order. A shared 2,000-item list needs single moves with stable keys. |
Functional requirements
- Reorder items by dragging with mouse, touch, and pen, with the other items moving aside to show where the item will land.
- Reorder with the keyboard: pick up, move with arrow keys, drop, or cancel, with every step announced to screen readers.
- Offer non-drag ways to move: Move up, Move down, and Move to position in an item menu.
- Support a drag handle, disabled items that cannot move, and pinned items that cannot be passed (for example a fixed first row).
- Support vertical lists, horizontal lists, and wrapping grids; optionally one or two levels of nesting.
- Report each finished move to the host app once, so it can save it, and accept the saved order back.
Non-functional requirements
- Smooth: the dragged item follows the pointer every frame, at 60 frames per second, so each frame has about 16 ms and our work should stay under 4 ms of it.
- Fast to start: a drag should begin within one frame of the activation rule (a 5 px move for mouse, a 250 ms press for touch).
- Accessible: meets WCAG 2.2, including 2.5.7 Dragging Movements, which asks for a way to do the same thing without dragging.
- Safe: a failed save rolls back to the last saved order and tells the person, never leaving the list in a state the server does not have.
- Small: the component should add well under 15 KB gzipped to the page.
Out of scope: dragging between several lists (that is the Kanban board), dragging files in from the desktop (the file uploader guide), and the screen around the list. We assume the host app gives us items and a save function.
Check your understanding · Exercise 1
Which answers from the interviewer change the design of the list itself? Select all that apply.
What makes a sortable list hard
The core idea of a sortable list is making room. While you drag item C downward, the items you pass slide up one slot to fill the gap C left, and a gap opens where C will land. The person always sees the final order before letting go. Nothing is saved, and the real data does not change, until the drop.
Four things make this harder than it looks. First, the drop index: the pointer is at y = 312 px, so where does the item go? With items of different heights there is no simple formula. Second, motion: the other items must slide, but if we reorder the DOM on every pointer move, React re-renders the list 60 times a second and the browser recomputes layout each time.
Third, inputs: a finger on a phone wants to scroll the page, a keyboard has no pointer at all, and a screen reader user cannot see the gap. Fourth, saving: the order the person sees is optimistic until the server agrees, and a person can make five moves in four seconds.
Some drag topics are the same for one list or many, and the Kanban board guide covers them in depth. Here is the short version of each, so we can spend this guide on what is special to one sortable list.
| Topic | Short version | Where to go deeper |
|---|---|---|
| Pointer events or HTML5 drag and drop | Pointer events give full control of the preview, touch, and animation; the native HTML5 API gives a free OS ghost image and drags between windows, but it is hard to style. Libraries split along this line: dnd-kit uses pointer events; Pragmatic drag and drop and SortableJS (by default) use the native API. | Kanban board guide, drag engine chapter |
| Collision detection | Deciding which target is under the dragged item. A single list only needs one rule: compare against item midpoints, which this guide builds in the next chapter. | Kanban board guide |
| Auto-scroll | When the pointer is within about 50 px of the scroll container's edge, scroll it each frame, faster the closer it gets. | Kanban board guide |
| Fractional ranks | Store a sortable string key per item so a move updates one row. Our persistence chapter uses it for shared lists. | Kanban board guide, ordering chapter |
| Concurrency | Remote moves that arrive mid-drag are queued until the drop, then rebased. | Kanban board guide |
| The drag state machine | Idle, pending, dragging, dropping, cancelling, plus edge cases such as the click after a drop and the long-press context menu. | Kanban board guide, drag lifecycle |
| Deep trees | Lazy children, expand on hover, moves between far-apart folders. | Tree view guide |
Finding the drop position with items of different heights
Every pointer move asks one question: if the person let go now, at which index would the item land? We call the answer the insertion index. Everything else (the gap, the shifting, the announcement, the saved order) comes from it.
The naive way is to swap with whatever item is under the pointer, using document.elementFromPoint or a pointerenter handler on each item.
Try it with a short task (48 px) dragged down onto a tall task (120 px). The moment the pointer enters the tall item, they swap: the tall item jumps up 48 px, and it is still under the pointer, so they swap back. The list flickers between two orders every frame. Items of the same height hide this bug; real lists with wrapped titles, images, and help text show it right away.
Decision How should the list decide the insertion index during a drag?
What we pick: Midpoints measured once. The thresholds come from the layout before the drag, which never changes during it, so the answer for a given position is always the same. That removes the swap feedback loop by design, and the per-move cost is a loop over numbers, not over the DOM.
The midpoint rule, worked through
Take five tasks with heights A 48, B 96, C 48, D 72, E 48 px. Stacked, their tops are 0, 48, 144, 192, 264, so their midpoints are 24, 96, 168, 228, 288. You grab A at its center (y = 24) and move down 180 px, so A's center is now at 204.
Which other items have a midpoint above 204? B (96) and C (168). That is two items, so the insertion index is 2: the new order would be B, C, A, D, E. B and C each slide up by A's height, 48 px, and the gap opens from 144 to 192.
We compare the dragged item's center, not the pointer, so it does not matter where on the item the person grabbed it. And we compare against the original midpoints, not the shifted ones: B crossing back is decided by the same number 96 whether B is shifted or not, so shifting can never feed back into the answer. A hand resting exactly on a midpoint can still toggle between two indexes; the Kanban board guide adds a small dead zone of about 8 px for that.
type Slot = { mid: number; height: number };
// One layout read, in content coordinates so scrolling is safe.
function measure(list: HTMLElement): Slot[] {
const origin = list.getBoundingClientRect().top - list.scrollTop;
return Array.from(list.children, (el) => {
const r = el.getBoundingClientRect();
return { mid: r.top - origin + r.height / 2, height: r.height };
});
}
// Index the dragged item would land at if dropped now.
function insertionIndex(slots: Slot[], from: number, centerY: number) {
let index = 0;
for (let i = 0; i < slots.length; i++) {
if (i !== from && slots[i].mid < centerY) index++;
}
return index;
}
Measuring 200 items costs one forced layout, usually a millisecond or two, and happens once. The count is a loop over 200 numbers, far under 0.1 ms. For 2,000 items a binary search over the sorted midpoints makes it logarithmic, but the linear loop is already fast enough.
Two edge cases. If the container scrolls during the drag (auto-scroll), the measured positions are in content coordinates, so we add the current scrollTop to the pointer's position instead of re-measuring. If an item's height really changes mid-drag (a lazy image loads), a ResizeObserver on the items marks the snapshot stale and we measure again on the next frame.
Check your understanding · Exercise 2
A list flickers between two orders when a short item is dragged over a tall one. Which changes fix it? Select all that apply.
Moving the other items: transforms during the drag, FLIP on drop
Now we know the insertion index on every move. The next question is how the other items move aside. The naive answer is to reorder the data array every time the index changes and let React re-render. The DOM order then follows the pointer, and the browser lays out the new order.
That breaks in three ways. Each index change re-renders the list and runs a full layout, which on a 300-item list with rich rows can take 8 to 15 ms, half the frame budget. Items jump to their new slots with no motion, so the eye loses track.
And moving the dragged element's DOM node can break the drag itself: a node that is removed and re-inserted can lose pointer capture and focus. A newer moveBefore() DOM method keeps that state, but it is not in every browser yet, so we cannot rely on it.
Decision How should items make room while the drag is in progress?
What we pick: Transforms during the drag, one real reorder on drop. During the drag the list is a picture of the future order painted on top of the old DOM. The data, React, and layout are touched once, when the person lets go.
Say the item moves from index from to index to and is h px tall (plus the gap between items). If from is less than to, every item at an index after from and up to to moves up by h. If to is less than from, every item from to up to just before from moves down by h; all others stay at 0. In the worked example, A (48 px) moved from 0 to 2, so B and C get translateY(-48px).
FLIP: animating a change of order
On drop we reorder the data. React moves the DOM nodes, and in the same frame we clear the shift transforms. The passed items do not move on screen, because their shifted positions are exactly their new layout positions. Only the dragged item is somewhere else: it is wherever the pointer left it.
To glide it into its slot we use FLIP, a technique named by Paul Lewis: First, Last, Invert, Play.
- First: before the change, record where the element is (
getBoundingClientRect().top). - Last: apply the change (reorder the data, let React commit), then measure the element again.
- Invert: set a transform that puts it back where it was:
translateY(first - last). On screen nothing has moved yet. - Play: animate that transform to
noneover about 200 ms. The element appears to slide from its old spot to its new one, using only the compositor.
transform, which needs no layout.The same code animates every order change that is not a pointer drag: keyboard moves, the Move up and Move down buttons, a rollback after a failed save, and moves made by teammates. That is why FLIP lives in the list, not in the drag code. Run it in useLayoutEffect, which fires after React changes the DOM but before the browser paints, so the person never sees the jump.
// First: call before the order changes.
function snapshot(list: HTMLElement) {
const tops = new Map<string, number>();
for (const el of Array.from(list.children) as HTMLElement[]) {
tops.set(el.dataset.id!, el.getBoundingClientRect().top);
}
return tops;
}
// Last, Invert, Play: call in useLayoutEffect after the new order commits.
function play(list: HTMLElement, first: Map<string, number>) {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
for (const el of Array.from(list.children) as HTMLElement[]) {
const before = first.get(el.dataset.id!);
const dy = before === undefined ? 0 : before - el.getBoundingClientRect().top;
if (dy !== 0) {
el.animate([{ transform: `translateY(${dy}px)` }, { transform: "none" }],
{ duration: 200, easing: "ease-out" });
}
}
}
With prefers-reduced-motion: reduce, we skip the slide and the shift transitions: items jump to their slots, and a 2 px drop line plus the announcement show what happened. Motion is a nice extra; it must never be the only signal.
Drag the real element or an overlay?
Decision What should follow the pointer: the item itself or a copy?
What we pick: An overlay by default, since a reusable list cannot know whether its host has clipping containers or virtualization. Offer the in-place mode as an option for simple lists in plain containers. dnd-kit makes the same split with its DragOverlay component.
Drag a song with the mouse, or press and hold one on touch. Keyboard: focus a handle button above and press Space.
Check your understanding · Exercise 3
Which statements about the drop and FLIP are true? Select all that apply.
Handles, whole-item dragging, and touch
Where can a drag start? In a Trello checklist or a photo grid, the whole item is the natural thing to grab. In a form builder, each field has a label input and a Required checkbox; if a press anywhere starts a drag, people cannot select text in the input, and a slightly shaky click on the checkbox becomes a tiny drag. On a phone it is worse: a finger on a list item usually means scroll the page.
Decision Where should a drag be allowed to start?
What we pick: Let the host choose, with the handle as the default for items that contain controls and whole-item dragging for plain items. The API is a handle render prop or a ref the item attaches to its grip. Even in whole-item mode we skip presses on interactive children, because a checkbox that starts a drag is a bug report waiting to happen.
Activation rules: telling a drag from a click and a scroll
A pointerdown is not a drag yet: the list waits in a pending state until an activation rule fires. The Kanban board guide walks through that drag state machine and its edge cases (pointer capture, text selection, the click after a drop, the long-press context menu), and a sortable list reuses it unchanged. We use the same numbers too: 5 px of movement for mouse and pen, and for touch a 250 ms press with under 5 px of movement, as in dnd-kit's docs.
What is special to a list is where touch-action goes. This CSS property decides who owns a touch: with the default auto, the browser can claim it for scrolling, then sends pointercancel and no more moves. touch-action: none on the handle only lets a handle drag start at once while the rest of the row still scrolls. Putting it on every item is a common first-version bug: a list that fills the screen can no longer be scrolled with a finger.
| Setup | Mouse and pen | Touch | touch-action |
|---|---|---|---|
| Handle | Start after 5 px from the handle | Start after 5 px from the handle; the row scrolls normally | none on the handle |
| Whole item | Start after 5 px anywhere, skipping controls | Start after a 250 ms press with under 5 px of movement; moving first means a scroll | auto or manipulation on items; once the drag starts, preventDefault() in a non-passive touchmove listener |
Check your understanding · Exercise 4
A form builder list with a drag handle on each field cannot be scrolled by touch on phones. Which causes are likely? Select all that apply.
Horizontal lists, wrapping grids, and nested lists
A vertical list is the base case. The same component often has to sort a horizontal strip of tabs, a grid of photos in a listing editor, or tasks with subtasks. Each layout changes how we turn a pointer position into a drop position. The rest (measure once, shift with transforms, reorder on drop, FLIP) stays the same.
Horizontal lists
A horizontal list is the vertical case turned on its side: measure left and width, compare the dragged item's center x against midpoints, and shift with translateX. Two details matter. In a right-to-left language the first item is on the right, so the index grows as x shrinks; read direction from the computed style and flip the comparison. And horizontal strips often scroll, so auto-scroll must run on the x axis.
Wrapping grids
A photo grid with 4 columns wraps 12 photos into 3 rows. Midpoints on one axis fail here: photo 5 (row 2, column 1) has a smaller x than photo 4 (row 1, column 4), so comparing x alone would put the order wrong.
Use two steps. First find the row: group the measured rectangles into rows by their top, and pick the row whose vertical band holds the dragged center. Then find the column inside that row with the usual midpoint rule on x. The index is the number of other items in the rows above plus the count in that row, skipping the dragged item in both.
Shifting in a grid is different too. When photo 2 moves to slot 7, photos 3 to 7 each step back one slot, and photo 5 jumps from the start of row 2 to the end of row 1. A single translateY cannot express that. Instead, each moving item gets translate(dx, dy) equal to the distance from its slot to the neighbor slot, both measured at drag start.
This works when cells share a size, which is true for most CSS grids. In a masonry layout the slot positions themselves change with order, so live shifting jumps around; there, show a drop line between items and reorder only on drop. dnd-kit's Sortable preset makes the same split with its sorting strategies: verticalListSortingStrategy and horizontalListSortingStrategy for one axis (both work with virtualized lists), and rectSortingStrategy, its default, for grids (no virtualization).
Nested lists: depth from horizontal offset
Todoist lets you drag a task to the right to make it a subtask, and the classic WordPress menu editor nests menu items the same way. In both, the vertical position picks where in the list the item goes, and the horizontal offset picks its depth. The trick is to treat the tree as a flat list of rows, each with a depth, which is how the screen shows it anyway.
- On drag start, collapse the dragged item's children into it: they move as one block, so the item can never be dropped inside itself.
- Find the insertion index in the flat list with the midpoint rule.
- Compute the wanted depth: the item's starting depth plus the horizontal drag distance divided by the indent width, rounded. With a 32 px indent, dragging 70 px right adds 2 levels.
- Clamp it. The maximum is the depth of the row above plus 1 (you can only become its child). The minimum is the depth of the row below (or the row below would lose its parent). Also cap at the product's limit, for example 2 levels.
- Show the projected depth live by indenting the drop indicator, then on drop set the parent to the nearest row above with a depth one less.
Deep trees with lazy-loaded children, expand on hover, and moves between far-apart folders are tree problems, covered in the tree view guide; block nesting in a document is covered in the Notion guide. For a sortable list, one or two levels with the depth rule above is enough.
Check your understanding · Exercise 5
In a nested list with a 32 px indent, a task at depth 0 is dragged 100 px right and dropped below a row at depth 1. The row below the drop point is at depth 0, and the limit is 2 levels (depths 0 and 1). What depth does it get? Pick one.
Keyboard reordering, screen readers, and focus
Dragging is a pointer gesture. People who use only a keyboard, a switch device, or a screen reader need another way to reorder. WCAG 2.2 success criterion 2.5.7 Dragging Movements (level AA) also covers pointer users who cannot hold and move: anything done by dragging must be possible with single clicks or taps too.
As far as we know there is no official pattern for this. The WAI-ARIA Authoring Practices Guide has patterns for listboxes, grids, and trees but none for drag and drop, and aria-grabbed and aria-dropeffect were deprecated in ARIA 1.1. The Kanban board guide builds the shared pieces (key scheme, live region, focus restore); here we cover what changes for one list.
Decision How should keyboard and screen reader users reorder items?
What we pick: Both, sharing one move(from, to) function. The menu is the path we can promise works everywhere, and it also satisfies 2.5.7. Keyboard drag is the fast path for sighted keyboard users. Atlassian's Pragmatic drag and drop guidelines go further: they recommend menu actions and advise against arrow-key moving for most experiences, partly because JAWS users must switch screen reader modes to use it.
Keyboard drag done well
- The handle is a real
buttonwhose name includes the item, like Reorder Write report, witharia-describedbypointing at short instructions. - The keys follow the convention react-beautiful-dnd popularized: Space picks up, arrows move, Space drops, Escape cancels. dnd-kit's keyboard sensor also accepts Enter, and so do we. A list adds Home and End to jump to the ends; Left and Right replace Up and Down in a horizontal list, and in a grid Up and Down move a whole row.
- Arrow presses only change the preview, with the same transforms as a pointer drag, so a person can explore positions freely. The drop fires one
onReorder; Escape, Tab, or a click elsewhere cancels and FLIPs the item home. - Disabled and pinned items are skipped by the arrows, and the announcement says why the item stopped.
Every step is announced through one polite live region (aria-live="polite") that the component renders once. Positions are 1-based and include the total, because Position 5 alone does not tell a blind user whether that is near the end. Library defaults are only a start: dnd-kit's default messages name the item's id (Picked up draggable item 3), and react-beautiful-dnd's give a position but not the item (You have lifted an item in position 1). Both let you replace them, and a list should.
| Moment | Announcement |
|---|---|
| Pick up | Picked up Write report, position 3 of 7. Arrow keys to move, Space to drop, Escape to cancel. |
| Each arrow press | Write report, position 4 of 7. |
| Drop | Write report moved from position 3 to position 5 of 7. |
| Cancel | Reorder cancelled. Write report is back at position 3 of 7. |
| Pinned item reached | Cannot move above Inbox, which is pinned. |
items, and the final announcement comes after that render, so it never claims a position the list does not show.Where focus goes after a move
Focus must follow the item, not stay at the old position. With stable key values React reuses the DOM nodes, but it reorders them with insertBefore, and a focused node moved that way can lose focus. Which nodes React moves depends on its diff, so do not rely on it. In the same layout effect that runs FLIP, check document.activeElement; if it is not the moved item's handle, call focus({ preventScroll: true }) on the handle, then scrollIntoView({ block: "nearest" }).
The menu has two traps. When Move up takes an item to position 1, Move up becomes disabled, and a focused button that becomes disabled drops focus to the body. Keep focus on the menu and move it to Move down instead.
And after Move to position, the menu closes, so return focus to the moved item's handle at its new place and announce the new position. The focus management lesson covers the general rule: never let focus fall to the body after an action.
Check your understanding · Exercise 6
Which choices make keyboard reordering work well? Select all that apply.
Saving the order: full lists, single moves, and rollback
The drop fires onReorder. Now the host app must save the order, and the list should already show it, because waiting 300 ms for the server after every drop feels broken. The naive save sends the whole ordered list of ids: PUT /lists/42/order with all ids in their new order.
For a private settings list of 12 items, that is about 300 bytes and it is the right answer. It has no conflicts to think about, and it repairs any drift because the server ends up with exactly what the person sees.
It breaks in two cases. Shared lists: Ana moves song 4 to the top while Ben moves song 9 to the end of the same playlist. Each sends a full order built from their screen; the second request silently undoes the first person's move. Big lists: a 2,000-song playlist with 30-byte ids sends 60 KB for every move, and a person reordering for a minute uploads megabytes.
Out-of-order arrival adds a third problem: five quick moves become five requests, and if request 3 arrives after request 5, the server keeps an old order.
Decision What should the client send to the server after a move?
What we pick: Pick by list type, behind the same onReorder event. Private lists under a few hundred items send the full order, because simplicity and self-healing beat a smaller payload there. Shared or large lists send single moves with fractional keys. The Kanban board guide explains rank generation, rebalancing, and conflicts in depth; the component only needs to report which item moved and its new neighbors.
A quick look at fractional keys: ranks a0, a1, a2 sort as strings. Moving an item between a0 and a1 gives it a key that sorts between them, such as a0V from the open-source fractional-indexing package. No other item changes, so Ben's move of a different item cannot collide with Ana's.
Optimistic reorder with rollback
The list shows the new order at once and keeps two things: the confirmed order (the last one the server accepted) and a queue of pending moves. What the person sees is the confirmed order with the pending moves applied.
When a save succeeds, its move joins the confirmed order. When it fails, we drop it from the queue, recompute, and FLIP animates the item back to where the server still has it, followed by a short message: Could not save the new order. Try again. The optimistic updates lesson covers this pattern in general; the list-specific part is that FLIP makes the rollback visible instead of a confusing jump.
Coalescing rapid moves into one save
People reorder in bursts: drag, adjust, adjust again, or press Move down five times. For full-order saves, wait 800 ms after the last move and send only the final order, and never have two saves in flight, so they cannot arrive out of order. For single-move saves, merge moves of the same item: if song 4 moved three times, only its final rank matters.
Before the page closes, flush the pending save with fetch(url, { keepalive: true }), which lets the request outlive the page. The browser caps the bodies of all keepalive requests in flight at 64 KiB in total, so a 2,000-id full order (about 60 KB) barely fits; one more reason large lists send single moves.
function createOrderSaver(
save: (ids: string[]) => Promise<void>,
onError: (error: unknown) => void,
wait = 800,
) {
let timer: ReturnType<typeof setTimeout> | undefined;
let latest: string[] | null = null;
let inFlight = false;
async function flush() {
if (inFlight || !latest) return;
const ids = latest;
latest = null;
inFlight = true;
try { await save(ids); } catch (error) { onError(error); }
finally {
inFlight = false;
// Moves made during the save wait for a new quiet period.
if (latest) { clearTimeout(timer); timer = setTimeout(flush, wait); }
}
}
return (ids: string[]) => {
latest = ids;
clearTimeout(timer);
timer = setTimeout(flush, wait);
};
}
Move songs, then wait 800 ms to save.
Check your understanding · Exercise 7
A team playlist of 1,500 songs is edited by several people. Which saving choices fit? Select all that apply.
Architecture, data model, and component API
With the mechanics settled, we can draw the pieces. The rule that shapes every boundary: the host app owns the order, the list owns the drag. The list never saves anything; it reports moves. The host never touches pointer events; it receives moves and passes back an order.
| Part | Job | Why it is separate |
|---|---|---|
SortableList | Renders items through renderItem, holds the drag state machine, applies shift transforms, runs FLIP on order changes | The only part that knows about layout; hosts never compute offsets |
| Sensors (pointer, keyboard) | Turn raw events into start, move, drop, cancel; apply activation rules | Adding a new input (a game controller, a voice command) means adding a sensor, not changing the list |
| Layout strategy (vertical, horizontal, grid, nested) | Measures slots and maps a position to an insertion index and offsets | The rest of the list does not care which layout is used |
| Announcer | One polite live region plus the message templates | Hosts can translate messages without touching drag code |
| Order store (host) | Confirmed order, pending moves, rollback | Lives in the host because only it knows the server |
| Save adapter (host) | Full-order or single-move requests, coalescing, keepalive flush | Swappable per list type |
items going in and a move coming out. Everything about pixels stays left; everything about the server stays right.Data model
Three kinds of state, in three places. The items and their saved order are server state, cached by the host (often with a library such as TanStack Query). The pending moves are host client state. The drag session (who is dragging, from where, the current insertion index, the measured slots) is component state inside the list, and it lives in a ref, not React state, so that pointer moves do not re-render the list.
type Item = { id: string; rank: string; parentId?: string; pinned?: boolean };
type Move = {
id: string; // the item that moved
from: number; // index before the move
to: number; // index after the move
beforeId?: string; // new neighbor above, for rank generation
afterId?: string; // new neighbor below
depth?: number; // nested lists only
};
type DragSession = {
activeId: string;
input: "pointer" | "keyboard";
from: number;
over: number; // current insertion index
slots: { mid: number; height: number }[]; // measured once
startScrollTop: number;
};
The component API
Decision What should the list report when an item is dropped?
What we pick: onReorder(move) as the main event, with an exported arrayMove(items, from, to) helper so simple hosts can still write one line. A move describes intent; a new array only describes a result, and turning a result back into intent is work every host would repeat. React Aria's useDragAndDrop makes a similar choice: its onReorder reports the moved keys and a target item with a drop position (before or after), not a new array.
type SortableListProps<T> = {
items: readonly T[];
getId: (item: T) => string;
getLabel: (item: T) => string; // for announcements
renderItem: (item: T, state: ItemState) => React.ReactNode;
onReorder: (move: Move) => void;
layout?: "vertical" | "horizontal" | "grid";
handle?: boolean; // drag from a grip only
canMove?: (item: T, to: number) => boolean; // pinned, limits
isDisabled?: (item: T) => boolean;
maxDepth?: number; // nested lists
messages?: Partial<Announcements>; // translations
};
type ItemState = {
isDragging: boolean;
handleProps: React.HTMLAttributes<HTMLElement>; // spread on the grip
moveActions: { up(): void; down(): void; to(n: number): void };
};
Constraints go through canMove. A settings list whose first row Inbox is pinned returns false for any move into index 0, so the insertion index is clamped at 1 and the announcement explains why. Disabled items can neither be dragged nor passed over: the layout strategy treats them as walls.
moveActions lets the host build its own menu while the list keeps announcements and focus consistent. The component API design lesson covers the controlled versus uncontrolled trade-off in general.
Check your understanding · Exercise 8
Why does the design report onReorder(move) rather than only onChange(newOrder)? Select all that apply.
Large lists and frame-by-frame performance
A 2,000-song playlist cannot render 2,000 rich rows; the virtualization lesson and the virtualized list guide explain why only the 20 or so rows in view, plus a small overscan, should be in the DOM. The Kanban board guide covers dragging inside a virtualized container in depth. In short: read slots from the virtualizer's size cache (measured or estimated) instead of the DOM, and keep the dragged row mounted with a custom range (TanStack Virtual's rangeExtractor), or the element holding pointer capture and focus disappears and the drag dies.
One point is special to a sortable list: shift only what is visible. Off-screen rows have no DOM to transform, so compute offsets for rendered rows only; a row that scrolls in during the drag gets its offset on mount from the same rule.
Auto-scroll is what makes long moves possible: near the edge of the scroll container, scroll a little each frame, faster the closer the pointer is. The Kanban board guide covers the speed curve. One list-specific point: auto-scroll changes scrollTop while the pointer stands still, so the insertion index must be recomputed on every scroll frame, not only on pointermove. For a move of 1,500 places, offer Move to position instead: nobody should hold a drag for 40 seconds.
Staying inside the frame budget
The fast-loop rules are the same as on a board, and the Kanban board guide's rendering chapter explains each: keep the drag session in a ref, do the work once per requestAnimationFrame, move the overlay by writing transform outside React, read layout only at drag start, and put will-change: transform on the overlay alone. For a sortable list the result is that React re-renders only when the insertion index changes, a few times per second.
Set budgets and check them in the Performance panel: per-frame script under 4 ms during the drag, and the drop (reorder, React commit, FLIP start) under 50 ms for a 300-row list, well inside the 200 ms threshold for a good Interaction to Next Paint. The drop is the one interaction INP sees, so keep the save off the critical path: update the order store first, start the request after.
Check your understanding · Exercise 9
In a virtualized sortable list, a drag stops working after the person drags an item about one screen down. What is the most likely cause and fix? Pick one.
Running it in the interview
A sortable list sounds small, so interviewers use it to see whether you go past the happy path. In 45 minutes, a strong answer spends about 5 minutes on requirements, 5 on the mental model and a wireframe, 20 on the three core mechanics (insertion index, shifting with FLIP, input and accessibility), 10 on the API and saving, and 5 on large lists and wrap-up.
Draw the wireframe first: a list of five rows of different heights, a handle on each, one item being dragged with a gap below it. Then say the mental model: the data changes only on drop, everything during the drag is a preview made of transforms. Write the midpoint rule with real numbers next to the drawing; doing the arithmetic out loud is the strongest signal you can give in this problem. Mention keyboard and the move menu before you are asked.
| Follow-up question | Strong answer |
|---|---|
| Why not use the HTML5 drag and drop API? | It gives a free OS preview and cross-window drags, but its preview is hard to style, touch support differs by browser, and it has no keyboard story. For a sortable list inside one page, pointer events give full control; the Kanban board guide compares both. |
| The list has 5,000 items. | Virtualize, keep the active row mounted, read slots from the virtualizer's size cache, auto-scroll, and offer Move to position for long moves. |
| Two people reorder at once. | Save single moves with fractional keys so different items never collide; queue remote moves during a drag and apply them on drop. |
| The save fails. | Keep confirmed order plus pending moves; drop the failed move, FLIP the item back, and show a message with retry. |
| How do you test it? | Unit-test the index and depth math with fixed rectangles; component tests for keyboard flows and announcements; end-to-end drags on a real touch device or emulation, and a screen reader pass. |
Think first
The interviewer asks: a product manager wants items to auto-sort by due date, but people can also drag. What do you say?
Dragging and a computed sort conflict: if the list is sorted by due date, a dragged item jumps back to its sorted spot on drop. Ask which order is the truth.
Common answer: dragging is allowed only in a Manual order mode; when sorted by date, hide the handles and the move menu, and switching back to manual restores the last manual order. Many task apps work this way.
Check your understanding · Exercise 10
Which moves in the interview show senior-level judgment for this problem? Select all that apply.
Summary
- The data order changes only on drop. During a drag, the list is a preview painted with transforms over an unchanged DOM.
- Find the insertion index by comparing the dragged item's center with item midpoints measured once at drag start; fixed thresholds remove the swap feedback loop that makes naive lists flicker with mixed heights.
- Shift passed items by the dragged item's height with
translateY, then on drop reorder once and use FLIP to glide the item into its slot; skip the motion with reduced motion. - Use a drag handle with
touch-action: nonewhen items contain controls; for whole-item drags on touch, start after a 250 ms press so scrolling still works. - Grids find the row first, then the column; nested lists take depth from horizontal offset, clamped by the rows above and below.
- Give keyboard users both a keyboard drag mode and a move menu, announce positions as 3 of 7, and keep focus on the moved item.
- Report
onReorder(move), not just a new array. Save small private lists as a full order and shared or large lists as single moves with fractional keys, optimistically with rollback and coalescing. - In virtualized lists, keep the dragged row mounted and read slot sizes from the virtualizer's cache.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- FLIP your animations Paul Lewis's original write-up of First, Last, Invert, Play.
- Pointer events MDN: pointer capture, pointercancel, and input types.
- touch-action MDN: telling the browser which touches it may use for scrolling.
- Element: animate() method MDN: the Web Animations API used for FLIP.
- Understanding SC 2.5.7: Dragging Movements W3C: why every drag needs a single-pointer alternative.
- Sortable preset dnd-kit docs: sortable strategies, sensors, and the drag overlay.
- Pragmatic drag and drop Atlassian's library built on the native drag and drop API.
- Pragmatic drag and drop: accessibility guidelines Menu actions, live regions, and focus after a move.
- Accessibility guide dnd-kit: keyboard sensor, screen reader instructions, and default announcements.
- Drag and drop in React Aria Keyboard and screen reader drag and drop, and the onReorder event.
- Realtime editing of ordered sequences Figma on fractional indexing for shared ordered lists.
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.
- 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.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- 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.
- 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.