DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Drag-to-reorder from scratch: the array is the source of truth, and the target slot is just Math.round(dy / rowHeight)

A drag-to-reorder (or sortable) list — the thing you use to rank a to-do list, reorder a playlist, or set column order — looks like it needs a heavy library. It doesn't. I built a full one from scratch: grab any row, the other rows slide live to open a gap where it'll land, release and the real array splice-reorders to match. No dependencies, Pointer Events so mouse/touch/pen all run one code path, plus a proper keyboard fallback. Here's the whole thing, piece by piece.

The array is the source of truth

The one idea everything hangs on: the order lives in a plain array of objects, and the DOM is rendered from it — never the other way round. You never read order back out of the DOM. So there's exactly one place order can change and one direction data flows, which means the picture and the data can't drift apart. Every reorder is just an edit to the array followed by a re-render. Each row is a real focusable element with a data-id so a handler can map a clicked row back to its object.

Grab it — pointerdown and the one critical line

On pointerdown I find the row with closest(".drag-item"), open a drag-state object, and — the single most important line — call setPointerCapture:

item.setPointerCapture(e.pointerId);  // drag survives leaving the row
Enter fullscreen mode Exit fullscreen mode

That reroutes every later move and the release to this element even when the pointer moves off it or off the list entirely. No more losing the drag because you moved too fast. I also measure the row height from two real rows (rows[1].top - rows[0].top) so the gap is never hard-coded, and add a dragging class to lift the row while the rest get a shifting class so only they animate.

Follow the pointer with a transform

With the pointer captured I attach pointermove to the dragged element. The delta is trivial and the held row follows with a transform — not top, not margins:

const dy = e.clientY - drag.startY;
drag.item.style.transform = `translateY(${dy}px)`;  // 1:1, no transition
Enter fullscreen mode Exit fullscreen mode

Transforms are composited on the GPU and never trigger layout, which is what keeps a drag at 60fps. The dragged row has no CSS transition so it tracks your finger exactly; only the other rows animate.

Where will it land? One question, not N rectangles

This is the heart of it, and simpler than people expect. You don't compare the pointer against every row's bounding box. You ask one question: how many whole rows have I travelled?

const target = Math.max(0, Math.min(n - 1,
  drag.startIndex + Math.round(dy / drag.rowH)));
Enter fullscreen mode Exit fullscreen mode

Because I round, the target flips to the next slot once you've dragged past a row's midpoint — exactly the "it swaps when I'm halfway over the next one" feel. I only act when the target actually changes, so rows shift once per crossing, not once per pixel. Then every row between the start and target slides one row-height to preview the gap — still pure transforms; nothing in the array has moved yet.

Drop — the splice the whole widget exists for

On pointerup the preview becomes real. Release the capture, then pull the item out and reinsert it at the target:

const [moved] = data.splice(startIndex, 1);   // pull out
data.splice(targetIndex, 0, moved);           // put back at target
Enter fullscreen mode Exit fullscreen mode

Then I re-render from data. The fresh rows are created in the new order with zero transforms, so they land exactly where the preview left them — no visible jump, because I never moved anything back.

The honest finish: keyboard + ARIA

A drag-only list excludes keyboard and screen-reader users, and because the array is already the source of truth, fixing it is tiny: focus a row, and ↑/↓ run the exact same splice the drop does — just with indices from the keyboard. Home/End jump to the ends. The container is a role="listbox", each row a role="option" stating its position, and every move pipes through one visually-hidden aria-live region so a screen reader hears it. touch-action:none makes a touch drag work instead of scrolling the page. That last bit is the line between a demo and something you can ship.

Try it — drag a row, or focus one and use the arrow keys:
https://dev48v.infy.uk/design/day44-drag-to-reorder.html

Top comments (0)