Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 4 of 15IntermediateAug 3, 2026

Drag and Drop

Design a reorderable list with pointer-based dragging, a correct new-index calculation, clear drop feedback, and a keyboard path that does the same job without a mouse.

frontend-system-designpractice-probleminteraction

The Problem

Design a vertical list whose items can be reordered by dragging. Grab an item, move it, drop it in a new position, and the order persists. While dragging, the list shows where the item will land.

Two things make this a real design problem rather than an afternoon's work. The first is that the platform API built for this is the wrong tool, and knowing why is most of the interview. The second is that dragging is a pointer-only interaction, so a correct implementation includes a second, non-drag path to the same outcome - which is why this problem is where Accessibility Architecture stops being abstract.

Requirements

Functional

  • Drag an item to a new position with mouse, touch or stylus.
  • Continuous visual feedback showing the pending drop position.
  • Reorder committed on drop; cancelled on Escape or on release outside the list.
  • Full keyboard alternative for picking up, moving and dropping an item.
  • New order persisted, with the UI reflecting the change immediately.

Non-functional

  • Pointer tracking holds the frame rate on a mid-range phone.
  • Touch dragging does not fight the browser's scroll and long-press gestures.
  • Every state change is announced to assistive technology.
  • A failed persistence request reverts the order visibly rather than silently diverging.

Native HTML5 Drag and Drop, and Why It Loses

The platform ships a drag API - draggable="true", then dragstart, dragover, dragleave, drop, dragend, with a DataTransfer object carrying the payload.

// The native approach. Concise, and unsuitable for a production reorderable list.
<li
  draggable
  onDragStart={(event) => {
    event.dataTransfer.setData('text/plain', item.id);
    event.dataTransfer.effectAllowed = 'move';
  }}
  onDragOver={(event) => {
    event.preventDefault(); // without this, drop never fires
    event.dataTransfer.dropEffect = 'move';
  }}
  onDrop={(event) => {
    event.preventDefault();
    reorder(event.dataTransfer.getData('text/plain'), item.id);
  }}>
  {item.label}
</li>

It is genuinely the right API for its actual purpose: dragging data between documents and applications - a file from the desktop into a browser, a selection from one window to another. For in-app reordering it fails on five counts:

  1. No touch support. Touch input does not generate drag events. Not "degraded" - absent. A native implementation needs a full parallel touch implementation, at which point you have written the pointer version anyway and are maintaining two.
  2. The drag image is barely yours. The browser snapshots the element and renders a translucent ghost. setDragImage accepts an element or image, with browser-specific rules about what must already be in the document and how it is positioned. Matching a design across Chrome, Safari and Firefox is a losing battle.
  3. dragover tells you almost nothing useful. It fires continuously with coordinates, but the payload is unreadable: for security reasons most browsers only expose DataTransfer contents during drop. You cannot ask "is this a droppable thing?" while deciding whether to show a drop indicator - you are guessing until the drop happens.
  4. The event sequence is quirky. dragenter and dragleave fire for child elements, so a target with any internal structure produces a stream of enter/leave pairs that must be counter-tracked. dragend fires on the source even after a cancelled drop, with inconsistent detail about what happened.
  5. Autoscroll is inconsistent. Dragging toward the edge of a scrollable container scrolls in some browsers, at some speed, sometimes.

Native DnD stays the right choice for one case: accepting files or content dragged in from outside the page, which is the only way to receive an OS-level drag. That is the file dropzone in File Upload System, not this list.

Pointer Events

Pointer events unify mouse, touch and stylus into one event model - pointerdown, pointermove, pointerup, pointercancel - with a pointerType telling you which device produced them. One code path, complete control over rendering, and everything that follows is your arithmetic rather than the browser's opinion.

Diagram
100%
stateDiagram-v2 [*] --> Idle Idle --> Pending: pointerdown on handle Pending --> Idle: pointerup before threshold (it was a click) Pending --> Idle: pointercancel Pending --> Dragging: moved > 5px, or held > 150ms (touch) Dragging --> Dragging: pointermove -> transform ghost,<br/>compute target index from cached rects Dragging --> Dragging: near container edge -> autoscroll Dragging --> Committing: pointerup Dragging --> Cancelled: Escape pressed Dragging --> Cancelled: pointercancel (call, gesture stolen) Committing --> Persisting: index changed -> apply optimistic reorder Committing --> Idle: index unchanged -> no-op Persisting --> Idle: server accepted Persisting --> Reverted: server rejected Reverted --> Idle: restore prior order, surface the error Cancelled --> Idle: drop ghost, restore original position
visualized byIOCombats

Two states in that machine exist purely because of real-world input. Pending is the threshold state that distinguishes a drag from a click or a scroll flick - without it, every tap on a handle starts a drag and the list becomes hostile on touch. Cancelled via pointercancel covers the browser taking the gesture away from you mid-drag: an incoming call, the OS interpreting the movement as a system gesture, the pointer leaving the window. Ignoring it leaves the UI stuck mid-drag with a ghost element pinned to nothing.

Drag state

Three things must be tracked, and separating them is what keeps the code honest:

type DragState = {
  /** The item being moved - its id and its index at drag start. */
  source: { id: string; index: number };
  /** Pointer offset within the grabbed element, so the ghost does not jump. */
  grabOffset: { x: number; y: number };
  /** Cached geometry, measured once at drag start. */
  rects: DOMRect[];
  /** Where the item would land if released now. */
  targetIndex: number;
};

The grabOffset is a small detail with a large effect: without it, the ghost snaps so its top-left corner sits under the pointer, and the item visibly jumps at the moment of grab. The rects cache is the performance decision discussed below.

function onPointerDown(event: React.PointerEvent, index: number) {
  // All subsequent events for this pointer come to this element, even if the
  // finger travels outside it. This replaces manual document-level listeners.
  event.currentTarget.setPointerCapture(event.pointerId);

  const items = Array.from(listRef.current!.children) as HTMLElement[];
  const rect = items[index].getBoundingClientRect();

  pendingRef.current = {
    pointerId: event.pointerId,
    startX: event.clientX,
    startY: event.clientY,
    index,
    grabOffset: { x: event.clientX - rect.left, y: event.clientY - rect.top },
    // One measurement pass for the whole gesture.
    rects: items.map((element) => element.getBoundingClientRect()),
  };
}

setPointerCapture is the piece most hand-rolled implementations miss. It routes every subsequent event for that pointer id to the capturing element, so a fast drag that outruns the cursor does not lose events when the pointer exits the element, and you do not need to attach and clean up document-level listeners.

The activation threshold

const DRAG_THRESHOLD_PX = 5;
const TOUCH_HOLD_MS = 150;

function onPointerMove(event: React.PointerEvent) {
  const pending = pendingRef.current;
  if (!pending || event.pointerId !== pending.pointerId) return;

  if (!isDragging) {
    const distance = Math.hypot(
      event.clientX - pending.startX,
      event.clientY - pending.startY,
    );
    // Mouse: a few pixels of travel. Touch: a short hold, so that a swipe
    // intended as a scroll is not stolen by the drag.
    const activated =
      event.pointerType === 'touch'
        ? Date.now() - pending.startedAt > TOUCH_HOLD_MS
        : distance > DRAG_THRESHOLD_PX;

    if (!activated) return;
    beginDrag(pending);
  }

  updateDrag(event.clientX, event.clientY);
}

Mouse and touch need different thresholds because the ambiguity differs. A mouse press that moves five pixels was clearly a drag. A finger that moves five pixels was probably starting to scroll, so touch waits for a hold instead - the "long press to reorder" convention users already know from mobile operating systems.

Keeping the browser out of the way

.drag-handle {
  /* Tell the browser not to claim this gesture for scrolling. Declarative,
     and cheaper than preventDefault on every move event. */
  touch-action: none;
  /* Stop a slow drag turning into a text selection. */
  user-select: none;
  -webkit-user-select: none;
  cursor: grab;
}

.drag-handle:active {
  cursor: grabbing;
}

Scoping touch-action: none to the handle rather than the whole item matters: on the full item it kills scrolling wherever the user happens to touch the list, which is a far worse bug than a slightly smaller grab area.

Computing the New Index

The core algorithm is midpoint comparison. Walk the cached rectangles and find the first item whose vertical centre is below the pointer - that is where the dragged item belongs.

function computeTargetIndex(
  pointerY: number,
  rects: DOMRect[],
  sourceIndex: number,
): number {
  for (let index = 0; index < rects.length; index += 1) {
    if (index === sourceIndex) continue; // the source is out of the flow

    const midpoint = rects[index].top + rects[index].height / 2;
    if (pointerY < midpoint) return index;
  }

  return rects.length - 1; // past every midpoint - drop at the end
}

Midpoints, not edges. Comparing against edges means an item only swaps once you have fully cleared it, which feels sticky and unresponsive. Crossing the centre is the moment a user has visually committed to the new position, which is why every polished implementation uses it.

Then the array move, where the off-by-one lives:

export function moveItem<T>(items: T[], from: number, to: number): T[] {
  if (from === to) return items;

  const next = [...items];
  const [moved] = next.splice(from, 1);
  // Splice AFTER removal. The array is now one shorter, so an index computed
  // against the original array overshoots by one for any downward move.
  next.splice(to, 0, moved);
  return next;
}

The bug this avoids is worth stating explicitly because it is the single most common defect in hand-written reorder code: dragging item 2 to position 5 in a naive implementation lands it at position 4, and the error only appears for downward moves, so it survives testing that only drags upward. Removing first and then inserting into the shortened array is correct in both directions.

Two adjustments for real lists. position: sticky headers and section dividers are children of the container but not reorderable, so filter them out of the measured set or their rectangles corrupt the index arithmetic. And cached rectangles go stale if the list scrolls mid-drag, so autoscroll must either offset the cached values by the scroll delta or trigger a re-measure - the cheaper option is tracking scrollTop at drag start and subtracting the delta, since it is arithmetic rather than layout.

Visual Feedback

Drag and drop visualizer

The dragged item and the drop position are two different signals and both are needed.

Diagram
100%
flowchart TB subgraph S1["1 - Idle"] A1["Item A"] A2["Item B ⣿ handle visible on hover"] A3["Item C"] end subgraph S2["2 - Dragging B, pointer between A and C"] B1["Item A"] BG["▁▁▁ placeholder gap (B's height) ▁▁▁"] B3["Item C"] BF["Item B - floating ghost, follows pointer,<br/>shadow + slight scale + reduced opacity"] end subgraph S3["3 - Invalid target (non-droppable zone)"] C1["Item A"] CF["Item B ghost - 'no-drop' cursor,<br/>indicator hidden, red edge"] C3["Item C"] end subgraph S4["4 - Dropped"] D1["Item A"] D2["Item B - animates from ghost position<br/>into the placeholder gap"] D3["Item C"] end S1 --> S2 --> S4 S2 -.-> S3 style S1 fill:#0f172a,stroke:#334155 style S2 fill:#1e3a5f,stroke:#3b82f6 style S3 fill:#3f1e1e,stroke:#ef4444 style S4 fill:#1e3f2d,stroke:#22c55e style BG fill:#1e3a5f,stroke:#3b82f6 style BF fill:#1e3a5f,stroke:#60a5fa
visualized byIOCombats

Three techniques, and the choice between the first two is a real design decision:

The placeholder gap. The source item is removed from the flow and a gap of exactly its height opens at the target position. The list reads as "the item will go here", and because sizes match, nothing shifts on drop. This is the strongest choice for uniform lists.

The insertion line. A 2px line between items instead of a gap. Cheaper - no reflow of siblings as the target changes - and the right answer for variable-height items, where opening a correctly-sized gap requires knowing the source height in a list where that varies.

The floating ghost. A copy of the item following the pointer, elevated with a shadow, slightly scaled, partly transparent. It must not intercept its own pointer events:

// The ghost sits under the pointer, so it must be invisible to hit testing,
// or every pointermove targets the ghost instead of the list beneath it.
ghost.style.pointerEvents = 'none';
ghost.style.position = 'fixed';
ghost.style.transform = `translate3d(${x}px, ${y}px, 0)`;

Move with transform, never with top and left. A transform is handled by the compositor; changing top invalidates layout on every pointer move, and layout is the most expensive stage of the pipeline. Combined with measuring once at drag start rather than per move, this is the difference between a drag that holds 60fps on a mid-range phone and one that stutters - the forced-synchronous-layout problem from Performance Engineering, in the one interaction where the main thread is busiest.

Two finishing touches. Animate siblings into their new positions with a short transform transition (~150-200ms) so the list resettles legibly instead of snapping. And respect prefers-reduced-motion by dropping those transitions - for some users the animation is not polish but a symptom trigger.

The Keyboard Path

Everything above requires a pointer. A reorderable list that only reorders by dragging is unusable for keyboard-only users, for screen reader users, and for anyone whose motor control makes a sustained precise drag difficult. The fix is not to synthesise drag events from keystrokes - it is a second interaction model that reaches the same outcome, which is exactly what the WAI-ARIA authoring practices describe for reorderable lists.

Two modes: normal, and "grabbed".

function ReorderableItem({ item, index, total, mode, onKey }: Props) {
  const isGrabbed = mode.type === 'grabbed' && mode.index === index;

  return (
    <li aria-label={`${item.label}, position ${index + 1} of ${total}`}>
      <button
        type='button'
        aria-pressed={isGrabbed}
        aria-describedby='reorder-instructions'
        onKeyDown={(event) => onKey(event, index)}
        className='drag-handle'>
        <GripVertical aria-hidden className='h-4 w-4' />
        <span className='sr-only'>
          {isGrabbed
            ? `Moving ${item.label}. Use arrow keys to reposition, Enter to drop, Escape to cancel.`
            : `Reorder ${item.label}. Press Enter to pick up.`}
        </span>
      </button>
      {item.label}
    </li>
  );
}
function onKey(event: React.KeyboardEvent, index: number) {
  const isGrabbed = mode.type === 'grabbed';

  switch (event.key) {
    case ' ':
    case 'Enter':
      event.preventDefault(); // Space would scroll the page
      if (isGrabbed) {
        commitReorder();
        announce(`${items[index].label} dropped at position ${index + 1}.`);
      } else {
        // Remember the original index so Escape can restore it.
        setMode({ type: 'grabbed', index, originalIndex: index });
        announce(
          `${items[index].label} picked up. Position ${index + 1} of ${
            items.length
          }.`,
        );
      }
      break;

    case 'ArrowUp':
    case 'ArrowDown': {
      if (!isGrabbed) return; // let the browser move focus normally
      event.preventDefault();

      const target = index + (event.key === 'ArrowDown' ? 1 : -1);
      if (target < 0 || target >= items.length) return;

      setItems((current) => moveItem(current, index, target));
      setMode({ ...mode, index: target });
      announce(`Position ${target + 1} of ${items.length}.`);
      break;
    }

    case 'Escape':
      if (!isGrabbed) return;
      event.preventDefault();
      setItems(originalItemsRef.current); // full restore, not an inverse move
      setMode({ type: 'idle' });
      announce('Reorder cancelled.');
      break;
  }
}

Four requirements make this genuinely usable rather than nominally accessible:

  • The handle is a real <button>. Focusable, activatable, announced as a control, and it gets keyboard behaviour for free. A <div> with a tabIndex and a click handler needs all of that rebuilt and usually gets it wrong.
  • Arrow keys only reorder while grabbed. Outside grabbed mode they must do what the user expects - move focus, or scroll. Hijacking arrows unconditionally breaks navigation of the surrounding page.
  • Every transition is announced through a polite live region. Without announcements the list changes silently and a screen reader user has no idea whether anything happened.
  • Escape restores the original array, not the inverse of the last move. After six arrow presses the user means "undo all of this", and applying one inverse move leaves them five positions from where they started.
{/* One shared live region for the whole list. */}
<div role='status' aria-live='polite' className='sr-only'>
  {announcement}
</div>

<p id='reorder-instructions' className='sr-only'>
  Press Enter or Space on a reorder button to pick up an item, then use the up
  and down arrow keys to move it. Press Enter or Space again to drop it, or
  Escape to cancel.
</p>

The keyboard path is also the cheapest thing to test, because it is deterministic - which is why it belongs in integration tests while the pointer path needs a real browser, per Testing Strategy.

Persisting the New Order

The reorder is applied locally on drop, then sent. Never wait for the round trip - a drop that hangs for 300ms before the item settles feels broken.

async function commitReorder(items: Item[], moved: Item, toIndex: number) {
  const previousOrder = itemsRef.current;
  setItems(items); // optimistic

  try {
    // Send the moved item and its neighbours, not the whole array. The server
    // can then reject a stale move instead of blindly accepting a full order
    // that was computed against data another client has since changed.
    await api.reorder({
      id: moved.id,
      afterId: toIndex > 0 ? items[toIndex - 1].id : null,
      beforeId: toIndex < items.length - 1 ? items[toIndex + 1].id : null,
    });
  } catch {
    setItems(previousOrder);
    showToast('Could not save the new order.');
  }
}

Sending neighbour ids rather than a full array is the more robust contract in any multi-client setting: it expresses intent ("put this between these two") rather than a snapshot that may already be stale, and it lets the server detect a conflict. It also pairs naturally with fractional or lexicographic ordering keys server-side, where inserting between two items means computing a key between theirs - so a reorder touches one row instead of renumbering the whole list.

The optimistic-then-revert shape is the same one used in Shopping Cart, and the reasoning for why it belongs in a shared store rather than component state is in State Management Architecture.

Common Interview Follow-Up Questions

"How do you handle dragging between two different lists, like a kanban board?" The state moves up: a single controller owns all lists so it can remove from one and insert into the other atomically, since the source list no longer owns the item. Each list registers itself as a drop target with its own measured rectangles, and the drag loop first resolves which container the pointer is over, then the index within it. Two extra cases appear: dropping into an empty column, which has no item rectangles to compare against and needs the container's own box as the target, and cross-list validation, where a column may refuse an item and must show that refusal during the drag rather than at drop time. On the keyboard path, arrows move within a list and a modifier or left/right arrows move between lists - and the announcement must name the destination list, or the user cannot tell where the item went.

"The list has 5,000 items. What breaks?" Measuring all of them at drag start. That is 5,000 getBoundingClientRect calls in one frame, and the drag begins with a visible stall. If the list is windowed only the rendered rows exist anyway, so measure those and compute indices in virtual space using the estimated row height for everything offscreen. Autoscroll then has to trigger loading and rendering of rows the user is dragging toward, which is where drag and windowing genuinely interact - see Virtualized List. For a long list, a "move to position…" input is often kinder than any amount of dragging.

"Would you use a library?" For anything beyond a single simple list, yes - and being able to say why is the point. dnd-kit or @atlaskit/pragmatic-drag-and-drop ship the parts that take longest to get right and are least visible in a demo: the keyboard and screen reader layer, autoscroll, pointer capture edge cases, pointercancel handling, sensor abstraction across input types, and collision strategies. A hand-rolled implementation typically ships the mouse path, then discovers touch, then discovers accessibility. What you should not do is reach for a library that wraps native HTML5 DnD, because it inherits every limitation listed above.

"How do you test this?" Split it. moveItem and computeTargetIndex are pure functions and deserve exhaustive unit tests, especially the downward-move off-by-one and the drop-at-end boundary. The keyboard path is deterministic and fully testable in jsdom - pick up, three arrows, drop, and assert both the resulting order and the live-region text. The pointer path needs a real browser driving pointerdown/pointermove/pointerup with coordinates, and it is worth one end-to-end test per critical list rather than exhaustive coverage. Visual regression catches the placeholder and ghost states that assertions cannot describe.

"An item is dropped while another user has already reordered the list. What happens?" With a full-array payload, one client's snapshot silently overwrites the other's - last write wins, and the first user's change vanishes with no signal. With the neighbour-id payload the server can detect that the referenced neighbours have moved and reject the operation, at which point the client reverts and refetches. If the list is live, the incoming reorder event should be applied to the local array unless a drag is in progress, in which case buffer it until drop - reordering the list under an active drag invalidates the cached rectangles and makes the item land somewhere the user did not aim. That buffering pattern is the same one used in Real-Time Feed.

Tradeoffs Table

OptionProsConsWhen to Use
Native HTML5 DnDVery little code, only way to accept OS-level drags, free autoscroll in some browsersNo touch support at all, drag image barely controllable, payload unreadable during dragover, quirky enter/leave sequenceAccepting files or content dragged in from outside the page
Pointer eventsOne path for mouse, touch and stylus; total control of visuals; capture solves lost eventsYou implement thresholds, autoscroll, cancellation and geometry yourselfAny in-app reordering or drag interaction
Placeholder gapReads unambiguously as "it goes here"; nothing shifts on dropSiblings reflow as the target changes; needs the source heightUniform-height lists
Insertion lineCheap, no reflow, works with variable heightsLess obvious how much space the item will occupyVariable-height lists, trees, nested structures
Full-array reorder payloadTrivial server contract, single source of truthSilently overwrites concurrent reorders; renumbers every rowSingle-user lists with no concurrent editing
Neighbour-id payloadExpresses intent, detects conflicts, one row updatedServer needs fractional or lexicographic ordering keysShared or collaborative lists

Where This Applies

Drag and drop is the clearest case in this track for the argument made in Accessibility Architecture: some interactions cannot be made accessible by adding attributes, and require a second interaction model built deliberately alongside the first. The pointer loop is also where the rendering-pipeline advice in Performance Engineering is most literal - transform instead of layout, measure once instead of per frame - because the main thread has the least headroom exactly when the user is dragging. Persisting the result is the optimistic-mutation pattern from State Management Architecture.

Within this track, the drop-zone half of the problem reappears in File Upload System, which is the one place native DnD is correct. Reordering inside a windowed list runs into the measurement problems of Virtualized List. And the full grid version of this interaction - resizable, rearrangeable panels whose layout is persisted per user - is Dashboard with Widgets.

Advertisement

Frequently Asked Questions

Why do most production applications avoid the native HTML5 drag and drop API?

Because it was designed for dragging data between documents and applications, not for reordering items inside a single view, and the mismatch shows up everywhere. The drag image is a browser-rendered snapshot you can barely control, styling it consistently across browsers is close to impossible, and the dragover event fires constantly with coordinates but no useful information about which item you are over. The data transfer object is only readable during the drop event in most browsers, so you cannot inspect what is being dragged in order to decide whether a drop is allowed. Most decisively, it does not work on touch devices at all, because touch never generates drag events, so a native implementation needs a completely separate touch implementation beside it. Pointer events give one code path for mouse, touch and stylus with full control over the visuals, which is why every serious library is built on them.

What is the actual algorithm for computing the new index when an item is dropped?

You compare the pointer position against the midpoints of the other items rather than against their edges. Measure each sibling's bounding box once at drag start, then during the drag find the first item whose vertical midpoint is below the pointer - that is the insertion point. Using midpoints rather than edges is what makes the interaction feel natural, because an item swaps as soon as you pass its centre instead of requiring you to fully clear it. The second subtlety is removing the dragged item from the array before inserting it, since a naive splice-in-place is off by one whenever the item moves downward - the array is one element shorter once the source is removed, so an insertion index computed against the original array overshoots by one.

How do you make a reorderable list usable without a mouse?

You provide a genuine keyboard alternative rather than trying to synthesise drag events from keystrokes. The WAI-ARIA pattern is a two-mode interaction - each item has a focusable grab handle, Space or Enter picks the item up and enters reorder mode, arrow keys move it one position at a time with the list updating live, and Space or Enter drops it while Escape cancels and restores the original position. Every state change is announced through a polite live region, so a screen reader user hears "Item picked up, position 3 of 8" and then each move. This is not a fallback for a minority - it is faster than dragging for many users, works for anyone with a motor impairment that makes precise dragging difficult, and it is the only version of the feature that works at all with a screen reader.

Why does touch dragging need touch-action and preventDefault handling that mouse dragging does not?

Because on a touch device the browser has its own plans for your finger. A vertical swipe scrolls the page, a long press opens a context menu or starts a text selection, and a horizontal swipe may trigger back-navigation. If you do nothing, the browser wins and your drag never starts. Setting touch-action to none on the drag handle tells the browser not to claim the gesture for scrolling, which is the declarative fix and cheaper than calling preventDefault on every move event. You also usually want a small activation delay or distance threshold before a touch becomes a drag, so that a tap or a scroll flick is not misinterpreted, and pointer capture so that the drag keeps receiving events even if the finger leaves the element.

What makes dragging feel janky, and how do you keep it smooth?

Two things. First, moving the dragged element by changing top or left triggers layout on every pointer move, and layout is the most expensive step in the rendering pipeline. Using a transform instead keeps the work on the compositor, so the element moves without the browser recalculating geometry. Second, reading layout during the drag - calling getBoundingClientRect on each move to work out where you are - forces a synchronous layout flush in the middle of the frame the browser is trying to paint. The fix is to measure once when the drag starts, cache the rectangles, and do pure arithmetic against that cache for the rest of the gesture. Between those two changes a drag that stutters on a mid-range phone becomes smooth, because per-move work drops from layout plus paint to a single compositor transform.