cf.completefrontend
My progress
SYSTEM DESIGN · #27

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.

QuestionAssumed answerWhat changes if the answer is different
How many items in one list?Usually 5 to 200; up to 2,000 for a long playlistUnder 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 tallerFixed 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 tooA 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 checkboxesWhole-item dragging would steal clicks and text selection, so we need a drag handle.
Which inputs?Mouse, touch, pen, keyboard, screen readersTouch 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 subtasksNesting 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 teamA 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.

Wireframe of a playlist being reordered. Five rows: Golden hour, Long road home (a taller row that has shifted up), a faded Sunrise placeholder at position 3 marking the insertion gap, Night drive, and Last call. Each row has a grip handle on the left and a move menu button on the right. A copy of Sunrise, the drag overlay, floats near the pointer. A hidden live region says position 3 of 5.Road trip playlist::Golden hour...::Long road home...::Sunrise...::Night drive...::Last call...Sunrisemove menutall row, shifted upoverlay follows pointerplaceholder = the gaplive region says:position 3 of 5grip on the left starts a drag
The faded placeholder sits where the item will land, so the gap and the final order are always visible. The overlay is only a picture that follows the pointer; the move menu on each row is the path that needs no dragging at all.

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.

Three snapshots of moving item C down one place in a list of A, B, C, D, E. Before: C is grabbed. While dragging: D has slid up into slot 3, slot 4 is an empty gap, and a copy of C floats over it; the data is still A B C D E. After drop: the list reads A B D C E and the data matches.beforeABCDEdata: A B C D Edragging C downABDEdata: still A B C D Eafter dropABDCEdata: A B D C EC
During the drag, D only looks moved: it carries a transform, and the array is untouched. The single real change happens on drop, which is why a cancel costs nothing.

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.

TopicShort versionWhere to go deeper
Pointer events or HTML5 drag and dropPointer 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 detectionDeciding 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-scrollWhen 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 ranksStore 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
ConcurrencyRemote moves that arrive mid-drag are queued until the drop, then rebased.Kanban board guide
The drag state machineIdle, 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 treesLazy 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?

Swap with the item under the pointer

Hit-test the pointer each move and swap the dragged item with the item found there.

Pros

  • Very little code.
  • Works for lists where every item has the same height.

Cons

  • Flickers with different heights, because the swap moves the target back under the pointer.
  • Depends on where you grabbed the item: grab it by its bottom edge and it moves late.

Live rectangles every move

Call getBoundingClientRect on every item on each pointermove and compare against their current, shifted positions.

Pros

  • Always reflects what is on screen right now.

Cons

  • Reading layout while items are animating returns positions in the middle of a slide, so the answer wobbles.
  • 200 rectangle reads per move, 60 times a second, can force layout and eat the frame budget.

Midpoints measured once at drag start

Measure every item once when the drag starts.

Each move, count how many other items have a midpoint above the dragged item's center.

Pros

  • Stable: thresholds never move during the drag, so there is no flicker.
  • Cheap: one layout read at start, then plain arithmetic per move.

Cons

  • Must be re-measured if the list's layout really changes mid-drag (an image loads, the window resizes).

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.

Five items of heights 48, 96, 48, 72, and 48 px stacked from y 0. Item A is being dragged and its center is now at y 204. Dashed lines mark the other items' midpoints: B at 96 and C at 168 are above 204; D at 228 and E at 288 are below. Two midpoints are above, so the insertion index is 2, and B and C slide up 48 px each.ABCDEB 96: aboveC 168: aboveD 228: belowE 288: belowAA center 204above 204:B and Cindex = 2B, C slide up48 px each
The thresholds are the midpoints from before the drag, so they never move. Counting midpoints above the dragged center gives the index directly, whatever the heights.

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.

ts
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;
}
Measure once at drag start, then a cheap count per move.

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?

Reorder the data on every index change

Splice the array on each new index and re-render; the DOM order changes during the drag.

Pros

  • Simple model: what you see is the array.
  • Works with any layout, since the browser places items.

Cons

  • A re-render and layout per index change.
  • No motion unless added separately.
  • Moving the dragged node can drop pointer capture and focus.

Shift with transforms, reorder on drop

Keep DOM order fixed during the drag.

Give each passed item translateY equal to the dragged item's height, with a CSS transition.

On drop, reorder the data once and clear the transforms.

Pros

  • Transforms skip layout and run on the compositor, so shifting is cheap.
  • The DOM is stable, so capture and focus are safe.
  • Motion comes free with transition: transform 200ms.

Cons

  • Needs the measured heights to compute offsets.
  • The drop needs care to avoid a visible jump (FLIP solves it).

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.

  1. First: before the change, record where the element is (getBoundingClientRect().top).
  2. Last: apply the change (reorder the data, let React commit), then measure the element again.
  3. Invert: set a transform that puts it back where it was: translateY(first - last). On screen nothing has moved yet.
  4. Play: animate that transform to none over about 200 ms. The element appears to slide from its old spot to its new one, using only the compositor.
FLIP in four panels for an item moving from a top slot to a lower slot. First: the item is in the top slot, measured at top 84. Last: after the reorder it is in the lower slot, measured at top 196. Invert: a transform of minus 112 px puts it back in the top slot, so nothing appears to move. Play: the transform animates to none over 200 ms and the item slides down into its slot.1 FirstSunrisetop 84measure2 LastSunrisetop 196reorder, measure3 InvertSunrise-112pxtransform back4 PlaySunriseto none200 ms slide
Steps 2 and 3 happen in the same frame, before paint, so the person never sees the jump. Only step 4 is visible, and it animates 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.

ts
// 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" });
    }
  }
}
FLIP for any order change, with the Web Animations API.

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?

Move the real item with a transform

Apply translate(dx, dy) to the grabbed item itself; its slot becomes the gap.

Pros

  • No duplicate render; the item keeps its state (a playing preview, an open input).
  • Least code.

Cons

  • Clipped by any ancestor with overflow: hidden or a lower stacking context.
  • Unmounted if the list virtualizes and its original slot scrolls away.

A drag overlay in a portal

Render a copy of the item in a portal at the body with position: fixed, and leave the original in place as a faded placeholder.

Pros

  • Never clipped and always on top.
  • The original can stay mounted for focus and measurements.
  • The preview can differ: smaller, tilted, or showing 3 items for a multi-select drag.

Cons

  • Renders the item twice; the copy has no local state.
  • Styles that depend on the list's ancestors must also apply in the portal.

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.

Press Space or Enter to pick up, arrow keys to move, Space or Enter to drop, Escape to cancel.

Drag a song with the mouse, or press and hold one on touch. Keyboard: focus a handle button above and press Space.

A playlist of 5 songs with heights Golden hour 48, Long road home 96, Sunrise 48, Night drive 72, Last call 48.drag a song up or downGolden hourLong road homeSunriseNight driveLast callnot dragging5 songsonReorder:not called yetdashed: midpointssolid: center
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?

Anywhere on the item

Listen for pointerdown on the whole item and start after an activation rule.

Pros

  • Big target, no extra icon.
  • Feels natural for cards, photos, and checklist rows.

Cons

  • Steals text selection and fights with inputs, links, and buttons inside the item.
  • On touch, every press on the list is a possible drag, so scrolling needs a delay rule.

A drag handle

Only a grip icon (six dots) starts a drag; the rest of the item behaves normally.

Pros

  • Inputs and links work as usual.
  • On touch, the handle can block scrolling and start at once, while the rest of the row scrolls.

Cons

  • A small target: it needs at least 24 by 24 CSS px (WCAG 2.5.8) and 44 px is better on phones.
  • Less discoverable for first-time users.

Whole item, but skip interactive children

Start from anywhere except when the press lands on an input, button, link, or contenteditable.

Pros

  • Big target that still lets controls work.

Cons

  • Text inside the item still cannot be selected.
  • Each host must mark custom controls, or they break in surprising ways.

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.

SetupMouse and penTouchtouch-action
HandleStart after 5 px from the handleStart after 5 px from the handle; the row scrolls normallynone on the handle
Whole itemStart after 5 px anywhere, skipping controlsStart after a 250 ms press with under 5 px of movement; moving first means a scrollauto 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.

A photo grid of 12 photos in 3 rows of 4. Photo 2 is being dragged; its slot is faded. Its center is in the band of row 2, between the midpoints of photos 7 and 8. The three other photos of row 1 come before it and it has passed three midpoints in row 2, so its index is 3 + 3 = 6: slot 7. Photos 3 to 7 each step back one slot, and photo 5 wraps up to the end of row 1.1234567891011122band: row 2row 1, others: 3row 2, passed: 3index 6 = slot 7hatched: stepback one slotphoto 5 wraps up to the end of row 1
Comparing x alone would fail: slot 5 has a smaller x than slot 4. Pick the row by its vertical band first, then use the midpoint rule inside the row.

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.

  1. 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.
  2. Find the insertion index in the flat list with the midpoint rule.
  3. 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.
  4. 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.
  5. 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.
A nested task list with an indent of 32 px. Rows: Plan trip at depth 0, Book flights at depth 1, Pack bags at depth 1, then the drop line, then Buy snacks at depth 0. The dragged task Call hotel has moved 100 px right, which asks for depth 3. The row above allows at most depth 2, the row below sets a minimum of 0, and the product allows only depths 0 and 1, so the drop line is drawn at depth 1.Plan tripBook flightsPack bagsabove: 1Buy snacksbelow: 0line at depth 1Call hoteldx 100wish: 3 (100 / 32)max: 1 + 1 = 2min: below = 0product cap: 1result: depth 1
Vertical position picks the index; horizontal offset only asks for a depth. The neighbors and the product's limit decide the depth you actually get, and the drop line shows it before you let go.

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?

Keyboard drag mode

Focus the handle, press Space or Enter to pick up, arrow keys to move, Space or Enter to drop, Escape to cancel.

Pros

  • Fast for keyboard users: three presses to move an item two places.
  • Uses the same preview and FLIP as the pointer drag.

Cons

  • Hidden: nothing on screen tells you the keys until you focus the handle.
  • Screen readers in browse mode may keep the arrow keys for reading, so the mode can fail for exactly the people who need it.

Move buttons and a Move to position field

Each item has a menu (or visible buttons) with Move up, Move down, Move to top, and Move to position with a number field.

Pros

  • Plain buttons work with every screen reader, switch device, and voice control.
  • Moving item 180 to position 3 is one step, not 177 arrow presses.

Cons

  • Slower for small moves, and the menu takes space.

Both

Keyboard drag on the handle for speed, plus the menu as the always-works path.

Pros

  • Every user has a path that fits them.
  • The menu also helps mouse users who find dragging hard, such as people with tremors.

Cons

  • Two paths to build and test; both must call the same move function.

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 button whose name includes the item, like Reorder Write report, with aria-describedby pointing 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.

MomentAnnouncement
Pick upPicked up Write report, position 3 of 7. Arrow keys to move, Space to drop, Escape to cancel.
Each arrow pressWrite report, position 4 of 7.
DropWrite report moved from position 3 to position 5 of 7.
CancelReorder cancelled. Write report is back at position 3 of 7.
Pinned item reachedCannot move above Inbox, which is pinned.
Sequence of a keyboard reorder between the user, the SortableList, the live region, and the host app. The user presses Space on the handle; the list announces picked up, 3 of 7. The user presses Arrow Down twice; the list moves the preview and announces position 4, then 5. The user presses Space to drop; the list calls onReorder with from 2 and to 4. The host replies with the items in the new order; the list re-renders, runs FLIP, keeps focus on the handle, and announces now 5 of 7.UserSortableListLive regionHost appSpace on handlepicked up, 3 of 7Arrow Down twice4 of 7, then 5 of 7Space to droponReorder(2, 4)items, new ordernow 5 of 7
Arrow presses change only the preview, so nothing reaches the host until the drop. The host answers with new 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?

The full ordered list of ids

Send every id in the new order; the server rewrites each item's position.

Pros

  • Simple, and self-healing: the server always ends up matching the screen.
  • No position math on either side.

Cons

  • Payload grows with the list: 60 KB for 2,000 items.
  • Last write wins: overwrites concurrent moves by others.
  • Rewrites many rows per move on the server.

A single move by index

Send { id, toIndex }; the server moves the item and shifts the others.

Pros

  • Tiny payload.
  • Easy to read in logs.

Cons

  • Indexes go stale: if someone else inserted an item above, index 5 now means a different place.
  • The server still renumbers many rows.

A single move with a fractional key

Each item has a sortable string rank. A move sends the item's new rank, generated between its new neighbors' ranks, or sends the neighbor ids and lets the server generate it.

Pros

  • One row changes per move.
  • Concurrent moves of different items both survive.
  • Payload stays about 100 bytes at any list size.

Cons

  • Keys grow longer after many moves into the same gap and need an occasional rebalance.
  • Two people dropping into the same gap need a tie-breaker.

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.

Sequence of saving a drop between the user, the SortableList, the order store, and the order API. The user drops Sunrise at position 5. The list calls onReorder with the move. The store adds it as pending and returns the optimistic order, which renders at once. The store sends PATCH with the new rank a0V. On success the API replies 200 and the move becomes confirmed. If the save fails, the API replies 500, the store drops the pending move and returns the confirmed order, and the list animates the item back with FLIP and shows a message.UserSortableListOrder storeOrder APIdrop at 5onReorder(move)optimistic orderPATCH rank a0V200: confirmedor, if it fails:500 errorconfirmed orderFLIP back, message
The person sees the new order before the request leaves. On a 500 the store does not guess a fix: it drops the failed move, renders the confirmed order, and FLIP makes the undo visible.

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.

State machine of saving the order. From synced, a move goes to pending, where a timer resets on each new move. After 800 ms with no moves, pending goes to saving, with one request in flight while new moves queue up. From saving, a 200 with nothing queued returns to synced, and a 200 with moves queued goes back to pending. An error goes to failed, which shows a message; from failed, retry goes back to saving, and roll back returns to synced with the confirmed order.pendingtimer resetssyncedmatches serversavingone in flightfailedshow messagemove800 ms quiet200, more queued200, queue emptyerrorretryroll back
Pending absorbs bursts: five quick moves become one save. Saving allows only one request in flight, so two saves can never arrive in the wrong order.
ts
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);
  };
}
Debounced, one-at-a-time saver for full-order lists.
SendList size

Move songs, then wait 800 ms to save.

Shown order: Golden hour rank c, Long road home rank f, Sunrise rank i, Night drive rank l, Last call rank o, Encore rank r. Confirmed order on the server: Golden hour, Long road home, Sunrise, Night drive, Last call, Encore. Pending moves: 0. Requests so far: 0, 0 B sent.shown (optimistic)confirmedGolden hourLong road homeSunriseNight driveLast callEncoreGolden hourLong road homeSunriseNight driveLast callEncorepending: 0requests: 0sent: 0 B1. Move songs, then wait 800 ms to save.
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.

PartJobWhy it is separate
SortableListRenders items through renderItem, holds the drag state machine, applies shift transforms, runs FLIP on order changesThe only part that knows about layout; hosts never compute offsets
Sensors (pointer, keyboard)Turn raw events into start, move, drop, cancel; apply activation rulesAdding 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 offsetsThe rest of the list does not care which layout is used
AnnouncerOne polite live region plus the message templatesHosts can translate messages without touching drag code
Order store (host)Confirmed order, pending moves, rollbackLives in the host because only it knows the server
Save adapter (host)Full-order or single-move requests, coalescing, keepalive flushSwappable per list type
Architecture. Inside the SortableList component: Sensors turn pointer and key events into drag events for the Drag session, kept in a ref. The Drag session asks the Layout strategy for slots and offsets, which go to Items with FLIP, rendered through renderItem. The Drag session also sends text to the Announcer live region and a position to the Overlay in a portal. In the host app, Items send a move to the Order store, which holds confirmed and pending order and passes items back. The Order store sends pending moves to the Save adapter, which sends PATCH requests to the Order API and gets a result back.SortableListSensorspointer, keysItems + FLIPrenderItemDrag sessiona refLayoutslots, offsetsAnnouncerlive regionOverlayportal, fixedeventsoffsetstextHost appOrder storeconfirmed + pendingSave adaptercoalesce, keepaliveOrder APIservermoveitemspendingPATCHresult
Only two things cross the line between list and host: items going in and a move coming out. Everything about pixels stays left; everything about the server stays right.
Data flow across four boxes: Order API, Query cache, Order store, SortableList, and the user. Reads go right: 1, GET items returns JSON with ranks into the cache; 2, the cache gives the server order to the store; 3, the store gives the shown order, confirmed plus pending, to the list. The user drops an item. Writes go left: 4, the list sends onReorder with the move to the store; 5, the store writes the optimistic order into the cache; 6, the cache's mutation sends PATCH with the new rank to the API, whose reply confirms or rolls back.Order APIQuery cacheOrder storeSortableList142536user1 GET items: JSON, ranks2 server order3 confirmed + pending4 onReorder(move)5 optimistic order6 PATCH rank, reply
Numbers 1 to 3 run on load and after every confirmed save; 4 to 6 run on each drop. The list never talks to the network, and the API never sees an index, only a rank.

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.

ts
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;
};
Client-side shapes. The drag session lives in a ref.

The component API

Decision What should the list report when an item is dropped?

onChange(newOrder)

Call back with the whole reordered array.

Pros

  • Fits useState directly: onChange={setItems}.
  • Matches controlled form inputs.

Cons

  • The host must diff arrays to find what moved, which it needs for single-move saves.
  • Easy to save the whole array by habit, with the problems from the previous chapter.

onReorder(move)

Call back with a Move: id, from, to, and the new neighbors.

Pros

  • Has everything a single-move save needs, without diffing.
  • Same event for pointer, keyboard, and menu moves.

Cons

  • The host applies the move itself (a three-line arrayMove helper).

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.

tsx
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 };
};
Props. The host owns items; the list reports moves.

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.

A virtualized playlist of 2,000 rows during a drag. Row 12, the one being dragged, stays mounted above the window even though it scrolled out of view. Rows 13 to 38 are not in the DOM. The viewport shows rows 39 to 43, with the overlay copy of row 12 floating over row 41. Rows 44 to 2,000 are not in the DOM. Slots come from the virtualizer's size cache, and the active index is always added to the rendered range.row 12 (kept)rows 13 to 38: no DOMrow 39row 40row 41row 42row 43row 12rows 44 to 2,000: no DOMviewportactive index alwaysin the rendered rangeslots from size cache:measured or estimatedindex re-checked onevery auto-scroll frame
Without the kept-mounted row, the element holding pointer capture and focus would vanish the moment it scrolled away, and the drag would die mid-gesture.

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 questionStrong 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?

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: none when 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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next