Swipe to Delete
Swipe a row to the left and action buttons slide in behind it. Let go past halfway and it stays open. Keep going and the delete button stretches across, so letting go deletes the row: it slides away and the rows below close the gap.
- AnaLunch on Friday?
- BenSlides are ready
- ChloeInvoice #2041
- DevSee you at 6
How to use it
- Paste the snippet just before
</head>. - Add
data-swipe-to-deleteto a list. Each child is a row. - Give the rows a background, so the buttons are hidden until you swipe.
- Listen for
deleteon the rows (it bubbles) to remove the item for real.
Options
Add any of these attributes to the same element.
data-actions | Comma-separated button labels. The last one deletes. Default Archive,Delete. |
data-action-width | Width of each button, in px. Default 76. |
data-restore | Milliseconds: deleted rows come back after this long. Handy for demos. |
data-autoplay | A pretend finger opens one row and deletes another, every so many ms while in view. Default 1600. A real swipe pauses it. |
Other buttons fire swipeaction on the row with detail.action set to the label, then close it. Vertical swipes still scroll the page.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Swipe to Delete: swipe a list row to the left to reveal action buttons
// that slide in behind it. Let go past halfway and the row stays open; keep
// going and the delete button stretches across, so letting go deletes the
// row, which slides away while the rows below close the gap.
// Usage:
// <ul data-swipe-to-delete>
// <li>Row one</li>
// <li>Row two</li>
// </ul>
// Each child is a row. Give rows a background so the actions hide behind them.
// Optional: data-actions (comma-separated button labels, default
// "Archive,Delete"; the last one deletes), data-action-width (px per
// button, default 76), data-restore (ms: deleted rows come back after this
// long, handy for demos), data-autoplay (a pretend finger swipes rows every
// so many ms while in view, default 1600; a real swipe pauses it).
// Events: a row fires "delete" when it is deleted, and "swipeaction" with
// detail { action } when another button is pressed. Both bubble.
var DEFAULTS = { actions: 'Archive,Delete', actionWidth: 76, autoplay: 1600 };
var css =
'.std{overflow:hidden;overflow-x:clip;overflow-y:visible}' +
'.std-row{position:relative;touch-action:pan-y;user-select:none;-webkit-user-select:none;will-change:transform}' +
'.std-acts{position:absolute;left:100%;top:0;bottom:0;display:flex;width:0;overflow:hidden;border-radius:inherit}' +
'.std-act{flex:1 1 0;min-width:0;display:grid;place-items:center;overflow:hidden;border:0;margin:0;padding:0;' +
'font:600 14px/1 system-ui,sans-serif;color:#fff;background:#8b5cf6;cursor:pointer;white-space:nowrap;' +
'transition:flex-grow .25s ease}' +
'.std-act:last-child{background:#e11d48}' +
'.std-armed .std-act{flex-grow:0}' +
'.std-armed .std-act:last-child{flex-grow:1}' +
'.std-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:50}';
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 labels = (d.actions || DEFAULTS.actions).split(',').map(function (t) { return t.trim(); }).filter(Boolean);
var aw = +d.actionWidth || DEFAULTS.actionWidth;
var openW = aw * labels.length;
var restore = +d.restore || 0;
var thumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('std');
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var open = null, userAt = -1e9;
var rows = Array.from(el.children).map(function (node) {
var r = { node: node, x: 0, css: node.style.cssText };
node.classList.add('std-row');
var acts = document.createElement('div');
acts.className = 'std-acts';
labels.forEach(function (label, i) {
var b = document.createElement(thumb ? 'span' : 'button');
if (!thumb) b.type = 'button';
b.className = 'std-act';
b.textContent = label;
b.tabIndex = -1;
b.addEventListener('click', function (e) {
e.stopPropagation();
if (i === labels.length - 1) remove(r);
else {
node.dispatchEvent(new CustomEvent('swipeaction', { bubbles: true, detail: { action: label } }));
settle(r, 0);
}
});
acts.appendChild(b);
});
if (thumb) acts.setAttribute('aria-hidden', 'true');
node.appendChild(acts);
r.acts = acts;
return r;
});
function setX(r, x) {
r.x = x;
anime.utils.set(r.node, { x: x });
r.acts.style.width = Math.max(0, -x) + 'px';
}
function arm(r, on) {
if (r.armed === on) return;
r.armed = on;
r.node.classList.toggle('std-armed', on);
}
// Snap to closed (0) or open (-openW).
function settle(r, to) {
var p = { x: r.x };
arm(r, false);
anime.animate(p, {
x: to,
duration: reduced() ? 0 : 380,
ease: 'out(3)',
onUpdate: function () { setX(r, p.x); },
});
if (to) {
if (open && open !== r) settle(open, 0);
open = r;
} else if (open === r) open = null;
r.acts.querySelectorAll('.std-act').forEach(function (b) { b.tabIndex = to ? 0 : -1; });
}
// Slide the row away, close the gap, then (optionally) bring it back.
function remove(r) {
if (r.gone) return;
r.gone = true;
if (open === r) open = null;
var w = r.node.offsetWidth, h = r.node.offsetHeight;
var cs = getComputedStyle(r.node);
var p = { x: r.x };
var fast = reduced();
arm(r, true);
anime.animate(p, {
x: -w,
duration: fast ? 0 : 260,
ease: 'in(2)',
onUpdate: function () { setX(r, p.x); },
onComplete: function () {
r.node.dispatchEvent(new CustomEvent('delete', { bubbles: true }));
r.node.style.overflow = 'hidden';
r.node.style.height = h + 'px';
r.node.style.boxSizing = 'border-box';
anime.animate(r.node, {
height: 0, paddingTop: 0, paddingBottom: 0, marginTop: 0, marginBottom: 0, opacity: 0,
borderTopWidth: 0, borderBottomWidth: 0,
duration: fast ? 0 : 320,
ease: 'inOut(2)',
onComplete: function () {
if (!restore) { r.node.remove(); return; }
setTimeout(function () { bringBack(r, h, cs); }, restore);
},
});
},
});
}
function bringBack(r, h, cs) {
arm(r, false);
setX(r, 0);
anime.animate(r.node, {
height: h,
paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom,
marginTop: cs.marginTop, marginBottom: cs.marginBottom,
borderTopWidth: cs.borderTopWidth, borderBottomWidth: cs.borderBottomWidth,
opacity: 1,
duration: reduced() ? 0 : 420,
ease: 'out(3)',
onComplete: function () {
r.node.style.cssText = r.css;
anime.utils.set(r.node, { x: 0 });
r.gone = false;
},
});
}
// Dragging: horizontal moves only, once the hand has clearly gone sideways.
var drag = null;
function start(r, px, py) {
if (r.gone) return;
drag = { r: r, sx: px, sy: py, x0: r.x, live: false, samples: [] };
}
function moveTo(px, py) {
if (!drag) return false;
var dx = px - drag.sx, dy = py - drag.sy;
if (!drag.live) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return false;
if (Math.abs(dy) > Math.abs(dx)) { drag = null; return false; }
drag.live = true;
if (open && open !== drag.r) settle(open, 0);
}
var r = drag.r, w = r.node.offsetWidth;
var x = drag.x0 + dx;
if (x > 0) x = x * 0.25; // a little give the wrong way
setX(r, Math.max(-w, x));
arm(r, -x > w * 0.6);
var now = performance.now();
drag.samples.push({ t: now, x: px });
while (drag.samples.length > 2 && now - drag.samples[0].t > 100) drag.samples.shift();
return true;
}
function end() {
if (!drag) return;
var r = drag.r, live = drag.live, s = drag.samples;
drag = null;
if (!live) { if (open) settle(open, 0); return; }
var v = s.length > 1 && performance.now() - s[s.length - 1].t < 80
? (s[s.length - 1].x - s[0].x) / ((s[s.length - 1].t - s[0].t) / 1000) : 0;
if (r.armed) remove(r);
else if (v < -500 || (v < 300 && r.x < -openW / 2)) settle(r, -openW);
else settle(r, 0);
}
if (!thumb) {
rows.forEach(function (r) {
r.node.addEventListener('pointerdown', function (e) {
if (e.button || e.target.closest('.std-acts')) return;
userAt = performance.now();
start(r, e.clientX, e.clientY);
});
r.node.addEventListener('pointermove', function (e) {
if (!drag || drag.r !== r) return;
userAt = performance.now();
if (moveTo(e.clientX, e.clientY) && !r.node.hasPointerCapture(e.pointerId)) r.node.setPointerCapture(e.pointerId);
});
r.node.addEventListener('pointerup', end);
r.node.addEventListener('pointercancel', function () {
if (drag && drag.r === r) { drag = null; settle(r, r.x < -openW / 2 ? -openW : 0); }
});
});
document.addEventListener('pointerdown', function (e) {
if (open && !open.node.contains(e.target)) settle(open, 0);
});
}
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var every = +d.autoplay || DEFAULTS.autoplay;
var finger = document.createElement('span');
finger.className = 'std-finger';
finger.setAttribute('aria-hidden', 'true');
el.appendChild(finger);
var visible = false, timer = 0, step = 0;
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(play, 500);
}).observe(el);
// Open a row and close it again, then long-swipe another to delete it.
function play() {
if (!visible) return;
var live = rows.filter(function (r) { return !r.gone && r.node.isConnected; });
if (performance.now() - userAt < 5000 || drag || !live.length) { timer = setTimeout(play, 1000); return; }
var kind = step % 3;
if (kind === 2 && open) { // close the open row with a tap
settle(open, 0);
step++;
timer = setTimeout(play, every * 0.6);
return;
}
var r = live[(kind === 0 ? 0 : 1) % live.length];
var box = el.getBoundingClientRect(), rb = r.node.getBoundingClientRect();
var w = rb.width;
var sx = rb.left - box.left + w * 0.8, sy = rb.top - box.top + rb.height / 2;
var dist = kind === 0 ? openW * 0.8 : w * 0.72;
var p = { x: sx };
anime.utils.set(finger, { x: sx, y: sy, scale: 1.3 });
anime.animate(finger, { opacity: 1, scale: 1, duration: 180, ease: 'out(2)' });
step++;
setTimeout(function () {
start(r, sx, sy);
anime.animate(p, {
x: sx - dist,
duration: kind === 0 ? 520 : 700,
ease: 'inOut(2)',
onUpdate: function () {
moveTo(p.x, sy);
anime.utils.set(finger, { x: p.x });
},
onComplete: function () {
setTimeout(function () {
end();
anime.animate(finger, { opacity: 0, scale: 0.7, duration: 240, ease: 'out(2)' });
timer = setTimeout(play, kind === 0 ? every : every + restore);
}, 120);
},
});
}, 200);
}
}
function init() {
document.querySelectorAll('[data-swipe-to-delete]').forEach(function (el) {
if (el._std) return;
el._std = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><ul data-swipe-to-delete data-actions="Archive,Delete">
<li>Lunch on Friday?</li>
<li>Slides are ready</li>
<li>See you at 6</li>
</ul>
<script>
document.querySelector('[data-swipe-to-delete]').addEventListener('delete', (e) => {
console.log('Deleted', e.target.textContent);
});
</script>