ESC

Type to search the knowledge base.

Drag and Drop List Reorder

Machine-coding brief for list reorder — pointer drag, keyboard move, optimistic order, and accessible announcements.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • dnd

Problem statement

Build a reorderable list: users drag items to a new index (and ideally can reorder with keyboard). Interviewers score index math, visual feedback, and whether you treat DnD as progressive enhancement over keyboard — not whether you pull in a heavy library (ask if libraries are allowed).

Requirements

Must have

  • Render ordered list from data
  • Drag handle or whole row draggable
  • Drop target indicator (line or gap highlight)
  • On drop, new order via onChange(nextItems)
  • Works with mouse / touch pointer events
  • Keyboard alternative: move item up/down with buttons or keys

Should have

  • aria-live announcement after reorder
  • Disable drag on interactive children (links, buttons) via handle-only drag
  • Smooth transform preview without layout thrash

Nice to have

  • Multi-column (kanban) — usually separate problem
  • Auto-scroll when dragging near edges
  • Persist order

Planning (5 minutes out loud)

  1. HTML5 DnD vs pointer events — HTML5 is faster to demo; pointer is more controllable
  2. Controlled list — parent owns order
  3. Drag index + hover index during gesture
  4. MVP — HTML5 reorder + up/down buttons; polish preview
  5. A11y is not optional — buttons for move if timeboxed

Architecture

ReorderList
├── ReorderItem
│   ├── DragHandle
│   ├── Content
│   └── MoveUp / MoveDown
└── live region

Data model

type Item = { id: string; label: string };

type ReorderListProps = {
  items: Item[];
  onChange: (items: Item[]) => void;
  getItemLabel?: (item: Item) => string;
};
function moveItem<T>(list: T[], from: number, to: number): T[] {
  if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
    return list;
  }
  const next = list.slice();
  const [spliced] = next.splice(from, 1);
  next.splice(to, 0, spliced);
  return next;
}

Implementation sketch — HTML5 DnD

function ReorderList({ items, onChange }: ReorderListProps) {
  const dragIndex = useRef<number | null>(null);
  const [overIndex, setOverIndex] = useState<number | null>(null);
  const [announce, setAnnounce] = useState("");

  function onDragStart(index: number) {
    dragIndex.current = index;
  }

  function onDragOver(e: React.DragEvent, index: number) {
    e.preventDefault(); // allow drop
    setOverIndex(index);
  }

  function onDrop(index: number) {
    const from = dragIndex.current;
    if (from == null) return;
    const next = moveItem(items, from, index);
    onChange(next);
    setAnnounce(
      `Moved ${items[from].label} to position ${index + 1} of ${items.length}`
    );
    dragIndex.current = null;
    setOverIndex(null);
  }

  function move(from: number, delta: number) {
    const to = from + delta;
    const next = moveItem(items, from, to);
    if (next === items) return;
    onChange(next);
    setAnnounce(`Moved ${items[from].label} to position ${to + 1}`);
  }

  return (
    <>
      <ul className="reorder-list" aria-label="Reorderable list">
        {items.map((item, index) => (
          <li
            key={item.id}
            draggable
            onDragStart={() => onDragStart(index)}
            onDragOver={(e) => onDragOver(e, index)}
            onDrop={() => onDrop(index)}
            onDragEnd={() => setOverIndex(null)}
            data-over={overIndex === index || undefined}
          >
            <span className="handle" aria-hidden>
              ⋮⋮
            </span>
            {item.label}
            <button type="button" aria-label={`Move ${item.label} up`} onClick={() => move(index, -1)}>
              Up
            </button>
            <button type="button" aria-label={`Move ${item.label} down`} onClick={() => move(index, 1)}>
              Down
            </button>
          </li>
        ))}
      </ul>
      <div className="sr-only" aria-live="assertive" aria-atomic="true">
        {announce}
      </div>
    </>
  );
}

Pointer-based sketch (more control)

// onPointerDown on handle → set draggingId, pointer capture
// onPointerMove → hit-test list items via elementFromPoint or bounding boxes
// onPointerUp → commit moveItem
// preview: transform: translateY during drag (optional)

Hit-testing midpoints: if pointer Y is above item center, insert before; else after. Collapse to target index carefully to avoid flicker.

Accessibility essentials

  • Never keyboard-trap only in drag mode
  • Provide Move up/down (or Alt+Arrow) always
  • Live region after reorder (assertive short message)
  • Drag handle: if whole row is draggable, ensure buttons still clickable (draggable on handle only is cleaner)
  • Visible drop indicator not color-only

Performance notes

  • During drag, avoid onChange every pixel — update UI local state; commit on drop
  • Large lists: virtualize + library; mention complexity of hit testing
  • Prefer transform for ghost preview over top/left layout

Footguns

  1. Forgetting preventDefault on dragover — drop never fires
  2. Using index as React key — identity bugs after reorder
  3. Stale closure on dragIndex in React state instead of ref mid-gesture
  4. Touch: HTML5 DnD is weak on mobile — pointer events or library
  5. Nested interactive controls accidentally starting drags

Interview out-loud answer

I’d keep the list controlled and implement moveItem(from, to). HTML5 drag-and-drop is fine for desktop MVP with dragover preventDefault and a drop highlight. I’d always ship Move up/down buttons and an aria-live announcement. If they need mobile polish, I’d switch to pointer events with hit-testing. Kanban multi-list is the same index math plus a target list id.

Further reading