GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Drag to Reorder UI: 9 Lists, One Drop Index

Drag to reorder UI: Computing the drop index from pointer position reorders lists, kanban boards and grids -- with long-press, Alt-arrow and edge autoscroll.

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine builds follow, and each one hands the pointer a slightly harder job than the last: item 01 only ever reorders a single flat list, item 02 adds a second container and a column boundary, item 03 collapses that boundary into a 2D grid of row/column indices, and items 04 through 09 each isolate one piece of real-world friction -- a small grip, a scroll-versus-drag decision, a keyboard-only path, a viewport edge, a tilting ghost, and a placeholder that has to remember a row's own height. Reading them in this order means every later item reuses a formula the one before it already introduced.

Demo

All nine boards replay a fixed drag sequence off a performance.now() clock instead of Math.random() or a CSS @keyframes loop, so the same row lands in the same slot in the same order every time the page loads. A real pointerdown, click, or Alt+Arrow press on any row stops that replay immediately and hands control back to whoever is actually holding the mouse, the finger, or the keyboard, and prefers-reduced-motion keeps every index calculation running while only dropping the transform that animates the settle.

Anatomy of drag reorder — pointerdown, move, up and setPointerCapture

Four events and one captured pointer

Six of the nine items -- 02, 04, 05, 07, 08 and 09 -- run their reorder on the same four events: pointerdown starts the gesture and calls setPointerCapture so the row keeps receiving pointermove even once the finger or cursor drifts outside its box, pointermove recomputes a target position on every frame, and pointerup or pointercancel commits the new order and releases the capture. Items 01, 03 and 06 compress the same index math into a single click or a pair of Alt+Arrow keys, so no pointer gesture is needed there at all. None of the nine reaches for the older HTML5 Drag and Drop API or juggles separate mouse and touch listeners -- one capture, one set of events, one code path wherever the pointer is involved.

Measure, invert, play, release

Reordering the DOM first and animating second is what keeps every list here from jumping: getBoundingClientRect() runs once before the reorder and once after, the difference between the two becomes a transform: translateY() applied with transition: none so the row appears to not have moved at all, and releasing that transform to zero on the next frame -- with a real transition this time -- is what turns a silent DOM swap into a visible glide. This is the FLIP pattern, and all nine items below build directly on it.

Code per list in nine steps

Paste any one item's prompt from the zip into an AI coding tool and it rebuilds the same board -- the block under each item below is trimmed straight out of that item's index.html, the same file that ships inside the password-protected download alongside a matching React version.

01FLIP list — computing the before/after offset and releasing it into a transition

A reorder that repaints straight into its new slot reads as a jump cut, so this item measures the offset first and lets the row travel the distance instead of teleporting across it.

Why the invert has to happen before paint

Appending every row in its new order and only afterward comparing each one's fresh top to its old one is what makes the FLIP math work: any row that actually moved gets a translateY() equal to exactly how far it would otherwise have jumped, and because that transform is applied with transition: none in the same synchronous pass, the browser never paints the jump at all.

FLIP list — computing the before/after offset and releasing it into a transition
Open in a new tab

01 · FLIP list — computing the before/after offset and releasing it into a transition — Measured area 6.12% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRecttransformtransition

What the numbers say

Painted area comes out lowest of the nine at 6.12% (peak frame 3.77%, average intensity 104.5), moving on 12 of its 23 frame-to-frame comparisons as the scripted sequence pauses between each swap to let the settle finish.

function applyOrder(order) {
  var rs = rows();
  var first = rs.map(function (r) { return r.getBoundingClientRect().top; });
  // reorder into the new sequence
  for (var i = 0; i < order.length; i++) list.appendChild(rs[order[i]]);
  rs = rows();
  var maxAbs = 0;
  for (var j = 0; j < rs.length; j++) {
    var dy = first[order[j]] - rs[j].getBoundingClientRect().top; // invert
    if (Math.abs(dy) > Math.abs(maxAbs)) maxAbs = dy;
    if (dy !== 0) {
      rs[j].style.transition = 'none';
      rs[j].style.transform = 'translateY(' + dy + 'px)';
    }
  }
  deltaEl.textContent = 'dy ' + Math.round(maxAbs) + 'px';
  // play -- release the invert on the next frame
  requestAnimationFrame(function () {
    rs.forEach(function (r) {
      r.style.transition = 'transform ' + DUR + 'ms cubic-bezier(.2,.8,.2,1)';
      r.style.transform = 'translateY(0)';
    });
    setTimeout(function () { rs.forEach(function (r) { r.style.transition = ''; }); }, DUR + 40);
  });
}

02Kanban card — inverting pointer Y ratio into a target column and insertion index

Dropping a card into whichever column happens to be under the pointer needs two separate lookups, so this item keeps the column test and the row-index test as two small functions instead of one tangled one.

Why a column edge alone can't place a card

Knowing which column the pointer is over only answers half the question -- the other half is where inside that column's card list the drop belongs, and comparing the pointer's Y against each card's own vertical midpoint is what turns a column into a specific insertion slot rather than just an append target.

Kanban card — inverting pointer Y ratio into a target column and insertion index
Open in a new tab

02 · Kanban card — inverting pointer Y ratio into a target column and insertion index — Measured area 25.115% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovegetBoundingClientRecttransition

What the numbers say

Painted area reaches 25.11% (peak frame 15.73%, average intensity 67.7), moving on 7 of 23 comparisons -- a low count that matches the scenario here holding a card mid-column between each cross-list move rather than repainting on every frame.

function findColumn(x) {
  for (var i = 0; i < COLS.length; i++) {
    var r = COLS[i].getBoundingClientRect();
    if (x >= r.left && x <= r.right) return i;
  }
  return -1;
}

// invert insertion index -- compare card midpoint Y with pointer Y
function insertIndex(colList, y) {
  var cards = colList.children;
  for (var i = 0; i < cards.length; i++) {
    var r = cards[i].getBoundingClientRect();
    if (y < r.top + r.height / 2) return i;
  }
  return cards.length;
}

03Grid reflow — inverting pointer coordinates into row/column indices

A grid needs two coordinates back from one pointer position, so this item divides both axes by a fixed track size instead of hit-testing every cell in turn.

Why the flat index only appears at the very end

Dividing the pointer's x and y by the board's column width and row height gives a column and a row separately, each one clamped so a drag that overshoots the last row still lands somewhere real, and only once both are known does row * COLS + col collapse them into the single flat index the rest of the grid's splice logic expects.

Grid reflow — inverting pointer coordinates into row/column indices
Open in a new tab

03 · Grid reflow — inverting pointer coordinates into row/column indices — Measured area 21.295% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

CSS custom propertiesgetBoundingClientRecttransition

What the numbers say

Painted area lands at 21.30% (peak frame 17.27%, average intensity 65.0), moving on 12 of 23 comparisons as the scripted drag steps between grid cells with a pause built into each hop.

function cellAt(x, y) {
  var r = board.getBoundingClientRect();
  var col = Math.floor((x - r.left) / (r.width / COLS));
  var row = Math.floor((y - r.top) / (r.height / Math.ceil(CELLS.length / COLS)));
  col = Math.max(0, Math.min(COLS - 1, col));
  row = Math.max(0, Math.min(Math.ceil(CELLS.length / COLS) - 1, row));
  return { col: col, row: row, idx: row * COLS + col };
}

04Handle-only — capturing the pointer on a small grip so the rest of the row stays interactive

Putting the pointer listeners on the whole row blocks every button and link inside it, so this item narrows setPointerCapture down to a six-dot grip instead.

Why the grip owns the pointer, not the row

Only the grip element receives pointerdown and calls setPointerCapture, which means a click anywhere else in the same row -- a checkbox, a delete button, a link -- keeps behaving like a normal click even while the row next to it is mid-drag, and the row's own movement is driven by a LERP-smoothed target rather than snapping straight to the raw pointer position.

Handle-only — capturing the pointer on a small grip so the rest of the row stays interactive
Open in a new tab

04 · Handle-only — capturing the pointer on a small grip so the rest of the row stays interactive — Measured area 34.737% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

setPointerCapturepointerdownCSS custom properties

What the numbers say

Painted area is 34.74% (peak frame 31.98%), the calmest average intensity of the nine at 42.0, moving on only 7 of 23 comparisons since the scenario here holds the lifted row still while it eases toward each new target instead of jittering every frame.

function beginDrag(row, clientY) {
  var r = row.getBoundingClientRect();
  row.classList.add('ho__row--drag');
  drag = { row: row, offsetInRow: clientY - r.top, pos: 0, target: 0 };
  tick();
}

function moveDrag(clientY) {
  if (!drag) return;
  var listRect = list.getBoundingClientRect();
  drag.target = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
  reflow(drag.row, clientY);
}

function tick() {
  if (!drag) return;
  drag.pos += (drag.target - drag.pos) * LERP;
  drag.row.style.setProperty('--ty', drag.pos.toFixed(2) + 'px');
  drag._raf = requestAnimationFrame(tick);
}

05Long-press gate — splitting scroll from drag with a hold timer and a movement threshold

A touch that starts a drag on the first pixel of movement fights a normal scroll gesture, so this item makes a finger wait before either one wins.

Why a hold timer alone still misreads a scroll

Starting a setTimeout on pointerdown and only calling activate() once HOLD_MS has fully elapsed catches the easy case, but a scroll can still creep a few pixels before the timer fires -- which is why startPress also tracks the touch's starting x and y so a real move past MOVE_PX can cancel the pending timer and let the scroll through untouched.

Long-press gate — splitting scroll from drag with a hold timer and a movement threshold
Open in a new tab

05 · Long-press gate — splitting scroll from drag with a hold timer and a movement threshold — Measured area 21.082% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

setTimeoutpointermovepointercancel

What the numbers say

Painted area is 21.08% (peak frame 18.91%, average intensity 97.0), moving on 16 of 23 comparisons -- the busiest count among the touch-timing items, since the on-screen readout keeps updating through the entire hold window before the drag itself even begins.

function pressTick() {
  if (!press) return;
  var el = press.pressStart === null ? 0 : performance.now() - press.pressStart;
  stateEl.textContent = 'press ' + Math.min(HOLD_MS, Math.round(el)) + 'ms';
  press.raf = requestAnimationFrame(pressTick);
}

function startPress(row, x, y) {
  cancelPress();
  press = { row: row, startX: x, startY: y, pressStart: performance.now(), raf: null, timer: null };
  press.timer = setTimeout(function () { activate(row, y); }, HOLD_MS);
  pressTick();
}

function cancelPress() {
  if (!press) return;
  if (press.timer) clearTimeout(press.timer);
  if (press.raf) cancelAnimationFrame(press.raf);
  press = null;
}

06Keyboard reorder — moving array indices with Alt+Arrow and announcing via aria-live

A drag built only on pointer events locks out anyone who can't use a mouse or a finger, so this item mirrors the same array-index move onto Alt+Arrow.

Why Alt+Arrow and aria-live replace the pointer entirely

onKeydown ignores every key press unless altKey is true, and once it fires, move() calls the exact same invert-and-release pair (flipSwap()) that the pointer-driven items run on every drop -- the only addition is a line written to a live region afterward, since a sighted drag shows its own result on screen but a keyboard-only reorder needs that result spoken instead.

Keyboard reorder — moving array indices with Alt+Arrow and announcing via aria-live
Open in a new tab

06 · Keyboard reorder — moving array indices with Alt+Arrow and announcing via aria-live — Measured area 15.117% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydownaria-liveCSS custom properties

What the numbers say

Painted area is 15.12% (peak frame 13.21%, average intensity 85.5), moving on 8 of 23 comparisons as the scripted Alt+Arrow presses land a few frames apart to leave each swap's glide time to finish.

function move(row, dir) {
  var j = flipSwap(row, dir);
  row.focus({ preventScroll: true });
  stateEl.textContent = 'Row reorder';
  liveEl.textContent = 'move ' + (j + 1) + '/' + rows().length;
}

function onKeydown(e) {
  if (!e.altKey) return;
  var row = e.target.closest('.ka__row');
  if (!row) return;
  if (e.key === 'ArrowUp') { stop(); e.preventDefault(); move(row, -1); }
  else if (e.key === 'ArrowDown') { stop(); e.preventDefault(); move(row, 1); }
}

07Edge autoscroll — an rAF loop mapping edge distance into scroll speed

Dragging past the bottom of a tall list with no autoscroll just stalls there, so this item turns how close the pointer sits to either edge into a live scroll speed.

Why edge distance maps to speed, not a fixed step

edgeFactor() returns a signed 0-to-1 ratio -- zero once the pointer clears EDGE_PX from either edge, growing toward 1 the closer it gets -- and multiplying that ratio by MAX_SPEED inside a running requestAnimationFrame loop is what makes the scroll accelerate smoothly into the edge rather than snapping straight to full speed the instant the pointer crosses a line.

Edge autoscroll — an rAF loop mapping edge distance into scroll speed
Open in a new tab

07 · Edge autoscroll — an rAF loop mapping edge distance into scroll speed — Measured area 41.662% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramescrollBygetBoundingClientRect

What the numbers say

Painted area is the widest of the nine at 41.66% (peak frame 20.93%) with the brightest average intensity too at 182.5, moving on 12 of 23 comparisons as the scripted drag hugs the viewport edge long enough for the scroll to visibly build speed.

function edgeFactor(pointerY, viewRect) {
  var topDist = pointerY - viewRect.top;
  var botDist = viewRect.bottom - pointerY;
  if (topDist >= 0 && topDist < EDGE_PX) return -(1 - topDist / EDGE_PX);
  if (botDist >= 0 && botDist < EDGE_PX) return (1 - botDist / EDGE_PX);
  return 0;
}

function tick(now) {
  if (!drag) { raf = null; stateEl.textContent = 'speed 0px/s'; return; }
  var dt = lastT ? (now - lastT) / 1000 : 0;
  lastT = now;
  var viewRect = view.getBoundingClientRect();
  var f = edgeFactor(drag.pointerY, viewRect);
  var speed = f * MAX_SPEED;
  if (speed !== 0 && dt > 0) view.scrollBy(0, speed * dt);
  stateEl.textContent = 'speed ' + Math.round(Math.abs(speed)) + 'px/s';
  raf = requestAnimationFrame(tick);
}

08Ghost preview — a trailing clone that turns move speed into tilt

A dragged row that stays perfectly level looks stiff next to how a real lifted card behaves, so this item reads how fast the pointer is moving sideways and tilts the row by that much.

Why the tilt comes from velocity, not drag distance

Every moveDrag() call measures the pointer's x change since the previous frame rather than since the drag started, multiplies that per-frame delta by K and clamps the result to MAX_ANGLE in either direction, so the row only leans while it's actually being swung and settles flat the instant the pointer stops moving sideways.

Ghost preview — a trailing clone that turns move speed into tilt
Open in a new tab

08 · Ghost preview — a trailing clone that turns move speed into tilt — Measured area 25.037% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovetransform: rotatetransition

What the numbers say

Painted area is 25.04% (peak frame 15.92%, average intensity 140.0), moving on 17 of 23 comparisons -- the second-busiest of the nine -- as the tilt reacts to every small sideways wobble in the scripted path.

function beginDrag(row, clientX, clientY) {
  var r = row.getBoundingClientRect();
  row.classList.add('gp__row--drag');
  drag = { row: row, offsetInRow: clientY - r.top, lastX: clientX, angle: 0, target: 0, raf: null };
  drag.raf = requestAnimationFrame(tick);
}

function moveDrag(clientX, clientY) {
  if (!drag) return;
  var listRect = list.getBoundingClientRect();
  var ty = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
  drag.row.style.setProperty('--ty', ty.toFixed(1) + 'px');
  var dx = clientX - drag.lastX;
  drag.lastX = clientX;
  var t = dx * K;
  if (t > MAX_ANGLE) t = MAX_ANGLE;
  if (t < -MAX_ANGLE) t = -MAX_ANGLE;
  drag.target = t;
  reflow(drag.row, clientY);
}

09Drop placeholder — carrying the lifted row's height into a growing gap

A lifted row that leaves no trace behind makes the rest of the list jump to fill an invisible hole, so this item drops a placeholder into that exact hole before the row goes anywhere.

Why the placeholder needs the lifted row's own height

beginDrag() reads the row's current height off getBoundingClientRect() before touching anything else, writes it onto the placeholder through a --h custom property, and only then inserts that placeholder right where the row used to sit -- skip that first measurement and the gap left behind would either collapse to zero or freeze at whatever height happened to be set last.

Drop placeholder — carrying the lifted row's height into a growing gap
Open in a new tab

09 · Drop placeholder — carrying the lifted row's height into a growing gap — Measured area 25.853% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

transitionCSS custom propertiesgetBoundingClientRect

What the numbers say

Painted area is 25.85% (peak frame 19.50%, average intensity 114.3), moving on 13 of 23 comparisons as the placeholder's slot tracks the lifted row across two moves in the scripted sequence.

function beginDrag(row, clientY) {
  var r = row.getBoundingClientRect();
  var listRect = list.getBoundingClientRect();
  placeholder.style.setProperty('--h', r.height + 'px');
  list.insertBefore(placeholder, row);
  row.classList.add('dd__row--drag');
  row.style.setProperty('--rowTop', (r.top - listRect.top) + 'px');
  row.style.setProperty('--rowW', r.width + 'px');
  row.style.setProperty('--ty', '0px');
  drag = { row: row, offsetInRow: clientY - r.top, listRect: listRect };
  var idx = Array.prototype.indexOf.call(list.children, placeholder);
  stateEl.textContent = 'slot ' + (idx + 1);
}

Where these nine actually get dragged

Reordering a to-do list by priority, moving a card across a kanban board, and rearranging entries in a settings menu are the three screens that ask for drag reorder most. Item 01's FLIP list fits a to-do list where even a single move should animate the whole stack into place, and item 02's kanban card fits a board where a drag has to cross columns, not just reorder within one.

For a visitor who can't use a pointer to reorder a settings menu, item 06's keyboard reorder gets the same result with just Alt+Arrow. All three screens share one requirement: pointer input and keyboard input have to land on the same array, so a structure that keeps both in one state fits them without extra work.

Position is math — FLIP coordinate inversion, autoscroll speed, and long-press threshold formulas

Inverting a pointer ratio into a column, a cell, and a scroll speed

Three different items above solve the same underlying problem with the same shape of formula: item 02 turns a pointer Y into a card index by comparing it against a set of midpoints, item 03 turns a pointer X and Y into a row and column by dividing each against a fixed track size, and item 07 turns a pointer's distance from an edge into a scroll speed by normalizing that distance against EDGE_PX. None of the three needs more than a handful of variables recomputed every frame -- much like a range slider that writes a single position variable straight from the pointer instead of storing a separate value for every possible stop, a drag and drop sortable list can afford to rerun a small ratio calculation on every pointermove rather than caching a result that pointer position would immediately invalidate.

From a movement threshold to a settle time

The other half of the math here is about time rather than space: item 05 only promotes a touch into a drag once HOLD_MS has elapsed without the pointer crossing MOVE_PX, item 04 and item 08 both ease a row's position toward a target with a LERP factor applied every frame instead of jumping straight to it, and item 01's DUR sets how long the FLIP release itself takes once the invert is computed. Raising any one of those numbers trades a snappier feel for a steadier one, which is exactly what the variants table further down measures directly.

Where it breaks — touch scroll conflicts, keyboard access, and long-list performance

Touch scroll versus drag intent

A touch surface has exactly one gesture channel, so a drag handler that reacts on the very first pointermove steals every scroll attempt that starts on top of a draggable row -- which is the whole reason item 05's hold timer and movement threshold exist, and why a plain sortable list ui without that gate frustrates anyone trying to scroll past a long list on a phone. Skip that gate on a real page and a drag and drop reorder list becomes unusable the moment it needs to sit inside anything scrollable.

Keyboard access has its own failure mode: a board that only listens for pointerdown locks out anyone tabbing through the page, and item 06's Alt+Arrow path is the only one of the nine that a screen-reader user can drive end to end -- drop that path from a real build and the rest of the pointer logic above becomes unreachable without a mouse. The full nine-item source, both plain and React, sits behind hyvft5vm in the zip linked from this post, the same way the swipeable list further down this site's pagination post keeps its own gesture thresholds behind a password rather than as a copy-paste snippet.

Long-list performance is the third seam: item 07's autoscroll runs a requestAnimationFrame loop for as long as the pointer sits near an edge, and item 04's tick() runs its own loop for as long as a row is being dragged -- both need to actually stop calling requestAnimationFrame on pointerup, or a page with several boards open at once ends up with more animation loops running than there are dragged rows to justify them.

Tune the hold timer and speed, and accessibility improves with it

Variant What changes What it trades off
Long-press hold time Item 05's HOLD_MS from 250 to 400 250ms reacts quickly even for a slow-moving finger; 400ms cuts false drag starts from casual scrolling but makes the drag itself feel one beat slower to begin
Autoscroll top speed Item 07's MAX_SPEED from 480 to 240 (px/s) 480px/s clears a long list fast but makes it easy to overshoot the target row; 240px/s gives back control at the cost of feeling slow on a very long list
FLIP settle time Item 01's DUR from 180 to 320 (ms) 180ms feels instant for a single swap; 320ms makes several rows sliding at once easier for the eye to follow, at the cost of a reorder that reads as sluggish

Reduced motion, item by item

prefers-reduced-motion: reduce erases the journey, never the result: items 01 through 06 detect it and run their scripted pass once with the transition stripped out -- where that pass reorders, the row lands in its final slot immediately while the index math, the array splice, and the aria-live announcement all run exactly as before, so the only thing missing on screen is the slide in between. Item 07 simply sets its scroll position once with no loop behind it, and items 08 and 09 skip the scripted demo entirely and sit still in their initial layout. A reduced-motion visitor still gets every drop index correctly computed wherever the reorder runs; only the visible travel to get there disappears.

Questions that come up once reorder ships

Do these nine drag-to-reorder patterns need a library like Sortable.js or interact.js?

No -- all nine items are vanilla HTML, CSS, and JavaScript with no framework, no CDN, and no Drag and Drop API: six of them (02, 04, 05, 07, 08, 09) are built directly on Pointer Events with setPointerCapture, and the other three (01, 03, 06) drive the same reorder math from a plain click or Alt+Arrow keys alone. The full source for every item, plus a matching React version, ships inside the password-protected zip linked from this post.

Why does the FLIP list glide instead of snapping straight to its new position?

Because the row is repositioned in the DOM first and only animated afterward: getBoundingClientRect() runs before and after the reorder, the difference becomes an instant transform applied with no transition, and releasing that transform to zero on the next frame with a real transition is what turns a silent jump into a visible glide -- the same invert-then-release pattern a drag and drop sortable list codepen demo usually shows off with a single item rather than nine.

Does a drag to reorder icon change any of the code, or just the cursor?

Barely -- item 04 already puts its pointer listeners on a small six-dot grip icon instead of the whole row, and swapping that icon's shape or size doesn't touch setPointerCapture, moveDrag(), or the LERP easing underneath it at all. The icon is a hit-target and a visual cue; the reorder math it triggers stays exactly the same.

Sources: MDN — Pointer Events · MDN — setPointerCapture · MDN — Element.getBoundingClientRect · MDN — prefers-reduced-motion

Enter the archive password

The password is inside this article. You will find it as you read.