← Gestures

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

  1. Paste the snippet just before </head>.
  2. Add data-swipe-to-delete to a list. Each child is a row.
  3. Give the rows a background, so the buttons are hidden until you swipe.
  4. Listen for delete on the rows (it bubbles) to remove the item for real.

Options

Add any of these attributes to the same element.

data-actionsComma-separated button labels. The last one deletes. Default Archive,Delete.
data-action-widthWidth of each button, in px. Default 76.
data-restoreMilliseconds: deleted rows come back after this long. Handy for demos.
data-autoplayA 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.

1 · Snippet
<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>
2 · Your HTML
<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>