Drag to Reorder
Pick up a list item and it lifts off the page. Drag it up or down and the other items slide aside to open a gap where it would land; let go and it settles into place.
- Write the brief
- Sketch ideas
- Pick a palette
- Build it
- Ship it
How to use it
- Paste the snippet just before
</head>. - Add
data-drag-to-reorderto a list. Each child is an item. - Optionally put an element with
data-handlein each item, so it drags by that handle only and the rest still scrolls on touch screens. - Listen for
reorderto save the new order.
Options
Add any of these attributes to the same element.
data-handle | On an element inside an item: drag by this handle only. |
data-autoplay | A pretend finger moves an item every so many ms while in view. Default 2400. A real drag pauses it. |
The list fires reorder with detail.item, detail.from and detail.to. From the keyboard, focus an item, press Space to pick it up, move it with the arrow keys and press Space again to drop it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Drag to Reorder: pick up a list item and it lifts off the page; drag it
// up or down and the other items slide aside to open a gap where it would
// land. Let go and it settles into the gap.
// Usage:
// <ul data-drag-to-reorder>
// <li>First</li>
// <li>Second</li>
// </ul>
// Each child is an item. Put an element with data-handle inside an item
// to drag by that handle only (the rest of the item then scrolls on touch).
// Optional: data-autoplay (a pretend finger moves an item every so many ms
// while in view, default 2400; a real drag pauses it).
// Keyboard: focus an item, press Space to pick it up, the arrow keys to move
// it, and Space again (or Escape) to drop it.
// Events: the list fires "reorder" with detail { item, from, to }.
var DEFAULTS = { autoplay: 2400 };
var css =
'.dtr-item{position:relative;will-change:transform;user-select:none;-webkit-user-select:none;transition:box-shadow .2s}' +
'.dtr-item:not(.dtr-has-handle),.dtr-handle{touch-action:none;cursor:grab}' +
'.dtr-lift{z-index:5;cursor:grabbing;box-shadow:0 14px 30px rgba(30,14,60,.18) !important}' +
'.dtr-lift,.dtr-lift *{cursor:grabbing}' +
'.dtr-item:focus-visible{outline:2px solid #6a0dad;outline-offset:2px}' +
'.dtr-finger{position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;' +
'background:rgba(30,14,60,.16);border:2px solid rgba(30,14,60,.32);pointer-events:none;opacity:0;z-index:20}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var thumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('dtr');
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var userAt = -1e9, drag = null;
var live = document.createElement('span');
live.setAttribute('role', 'status');
live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)';
if (!thumb) el.parentNode.insertBefore(live, el.nextSibling);
function items() {
return Array.from(el.children).filter(function (n) { return n.classList.contains('dtr-item'); });
}
Array.from(el.children).forEach(function (n) {
n.classList.add('dtr-item');
var h = n.querySelector('[data-handle]');
if (h) { n.classList.add('dtr-has-handle'); h.classList.add('dtr-handle'); }
if (!thumb) n.tabIndex = 0;
});
function lift(n, on) {
n.classList.toggle('dtr-lift', on);
anime.animate(n, { scale: on ? 1.035 : 1, duration: reduced() ? 0 : 220, ease: 'out(2)' });
}
function start(n, y) {
var list = items();
var tops = list.map(function (m) { return m.offsetTop; });
var hs = list.map(function (m) { return m.offsetHeight; });
var from = list.indexOf(n);
// The distance an item moves to make room: the dragged item's height plus the gap.
var step = list.length > 1
? (from < list.length - 1 ? tops[from + 1] - tops[from] : tops[from] - tops[from - 1] - hs[from - 1] + hs[from])
: hs[from];
drag = { n: n, list: list, tops: tops, hs: hs, from: from, to: from, y0: y, dy: 0, step: step, shift: list.map(function () { return 0; }) };
lift(n, true);
}
function moveTo(y) {
if (!drag) return;
var g = drag, list = g.list, from = g.from;
var min = g.tops[0] - g.tops[from];
var max = g.tops[list.length - 1] + g.hs[list.length - 1] - g.hs[from] - g.tops[from];
g.dy = Math.max(min - 12, Math.min(max + 12, y - g.y0));
anime.utils.set(g.n, { y: g.dy });
var mid = g.tops[from] + g.dy + g.hs[from] / 2;
var to = from;
list.forEach(function (m, i) {
var c = g.tops[i] + g.hs[i] / 2;
if (i > from && mid > c) to = i;
if (i < from && mid < c && to === from) to = i;
});
if (to === g.to) return;
g.to = to;
list.forEach(function (m, i) {
if (m === g.n) return;
var s = (i > from && i <= to) ? -g.step : (i < from && i >= to) ? g.step : 0;
if (s === g.shift[i]) return;
g.shift[i] = s;
anime.animate(m, { y: s, duration: reduced() ? 0 : 240, ease: 'out(3)' });
});
}
function end() {
if (!drag) return;
var g = drag;
drag = null;
var target = 0;
g.list.forEach(function (m, i) { if (g.shift[i]) target -= g.shift[i]; });
anime.animate(g.n, {
y: target,
duration: reduced() ? 0 : 260,
ease: 'out(3)',
onComplete: function () {
// Put the item where it now looks to be, then clear the offsets.
var list = g.list;
if (g.to !== g.from) {
var ref = g.to > g.from ? list[g.to].nextSibling : list[g.to];
el.insertBefore(g.n, ref);
}
list.forEach(function (m) { anime.utils.set(m, { y: 0 }); });
lift(g.n, false);
if (g.to !== g.from) {
el.dispatchEvent(new CustomEvent('reorder', { detail: { item: g.n, from: g.from, to: g.to } }));
live.textContent = 'Moved to position ' + (g.to + 1) + ' of ' + list.length;
}
},
});
}
// Keyboard: move one place at a time, animating everything that shifts.
var held = null;
function step(n, dir) {
var list = items(), i = list.indexOf(n), j = i + dir;
if (j < 0 || j >= list.length) return;
var before = list.map(function (m) { return m.getBoundingClientRect().top; });
el.insertBefore(n, dir > 0 ? list[j].nextSibling : list[j]);
list.forEach(function (m, k) {
var dy = before[k] - m.getBoundingClientRect().top;
if (dy) anime.animate(m, { y: [dy, 0], duration: reduced() ? 0 : 240, ease: 'out(3)' });
});
n.focus();
el.dispatchEvent(new CustomEvent('reorder', { detail: { item: n, from: i, to: j } }));
live.textContent = 'Moved to position ' + (j + 1) + ' of ' + list.length;
}
if (!thumb) {
el.addEventListener('pointerdown', function (e) {
if (e.button || drag) return;
var n = e.target.closest('.dtr-item');
if (!n || n.parentNode !== el) return;
if (n.classList.contains('dtr-has-handle') && !e.target.closest('.dtr-handle')) return;
e.preventDefault();
userAt = performance.now();
n.setPointerCapture(e.pointerId);
start(n, e.clientY);
});
el.addEventListener('pointermove', function (e) {
if (!drag) return;
userAt = performance.now();
moveTo(e.clientY);
});
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
el.addEventListener('keydown', function (e) {
var n = e.target;
if (!n.classList || !n.classList.contains('dtr-item') || n.parentNode !== el) return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
userAt = performance.now();
if (held === n) { held = null; lift(n, false); live.textContent = 'Dropped'; }
else {
if (held) lift(held, false);
held = n; lift(n, true);
live.textContent = 'Picked up. Use the arrow keys to move it, Space to drop it.';
}
} else if (held === n && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
userAt = performance.now();
step(n, e.key === 'ArrowUp' ? -1 : 1);
} else if (held === n && e.key === 'Escape') {
held = null; lift(n, false);
}
});
el.addEventListener('focusout', function (e) {
if (held && e.target === held && !el.contains(e.relatedTarget)) { lift(held, false); held = null; }
});
}
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var every = +d.autoplay || DEFAULTS.autoplay;
var finger = document.createElement('span');
finger.className = 'dtr-finger';
finger.setAttribute('aria-hidden', 'true');
el.appendChild(finger);
var visible = false, timer = 0, turn = 0;
// Moves to make: [from, to], cycling.
var MOVES = [[0, 2], [3, 1], [1, 3], [2, 0]];
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(play, 600);
}).observe(el);
function play() {
if (!visible) return;
var list = items();
if (performance.now() - userAt < 5000 || drag || held || list.length < 2) { timer = setTimeout(play, 1000); return; }
var mv = MOVES[turn++ % MOVES.length];
var from = mv[0] % list.length, to = mv[1] % list.length;
if (from === to) to = (from + 1) % list.length;
var n = list[from];
var grip = n.querySelector('.dtr-handle') || n;
var gx = grip.offsetLeft + grip.offsetWidth / 2 + (grip === n ? n.offsetWidth * 0.3 : 0);
if (grip !== n && grip.offsetParent !== el) {
var gr = grip.getBoundingClientRect(), er = el.getBoundingClientRect();
gx = gr.left - er.left + gr.width / 2;
}
var y0 = n.offsetTop + n.offsetHeight / 2;
// Aim just past the target's middle, so the item clearly takes its place.
var y1 = list[to].offsetTop + list[to].offsetHeight / 2 + (to > from ? 6 : -6);
var p = { y: y0 };
anime.utils.set(finger, { x: gx, y: y0, scale: 1.3 });
anime.animate(finger, { opacity: 1, scale: 1, duration: 180, ease: 'out(2)' });
setTimeout(function () {
if (drag || held) { timer = setTimeout(play, 1000); return; }
start(n, y0);
setTimeout(function () {
anime.animate(p, {
y: y1,
duration: 300 + Math.abs(to - from) * 220,
ease: 'inOut(2)',
onUpdate: function () {
moveTo(p.y);
anime.utils.set(finger, { y: p.y });
},
onComplete: function () {
setTimeout(function () {
end();
anime.animate(finger, { opacity: 0, scale: 0.7, duration: 240, ease: 'out(2)' });
timer = setTimeout(play, every);
}, 160);
},
});
}, 220);
}, 200);
}
}
function init() {
document.querySelectorAll('[data-drag-to-reorder]').forEach(function (el) {
if (el._dtr) return;
el._dtr = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><ol data-drag-to-reorder>
<li>Write the brief <span data-handle>⠿</span></li>
<li>Sketch ideas <span data-handle>⠿</span></li>
<li>Build it <span data-handle>⠿</span></li>
</ol>