← Buttons

Hold to Confirm

For actions you can’t undo. Press and hold, and a fill grows across the button; let go early and it drains away. Hold until it’s full and the button pops, a tick draws itself in and it fires a confirm event.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-hold-to-confirm to a button.
  3. Do the real thing when it fires its confirm event.

Options

Add any of these attributes to the same element.

data-durationHow long to hold. Default 1400 ms.
data-doneLabel once confirmed. Default “Done”.
data-colorThe fill colour. Default white at 30%.
data-resetHow long before it’s ready again. Default 1800 ms.
data-autoplayIt holds itself every few seconds while in view, letting go early every other time. Handy for demos.

Space and Enter can be held too, and screen readers hear the result. The fill still grows with reduced motion, since it shows how long is left.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hold to Confirm: for actions you can't undo. Press and hold the button and
    // a fill grows across it; let go early and it drains away. Hold until it's
    // full and the button pops, says it's done and fires a "confirm" event.
    // Space and Enter can be held too.
    // Usage: <button data-hold-to-confirm data-done="Deleted">Hold to delete</button>
    // Optional: data-duration (ms to hold, default 1400), data-done (label once
    // confirmed, default "Done"), data-color (the fill, default
    // rgba(255,255,255,.3)), data-reset (ms before it's ready again, default
    // 1800), data-autoplay (it holds itself every few seconds while in view,
    // letting go early every other time; handy for demos).
    // Listen for the "confirm" event to do the real thing.
    var DEFAULTS = { duration: 1400, done: 'Done', color: 'rgba(255,255,255,.3)', reset: 1800, every: 3600 };

    var css =
      '.htc{position:relative;overflow:hidden;isolation:isolate;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
      '.htc-fill{position:absolute;inset:0;z-index:-1;pointer-events:none;background:var(--htc-color);transform-origin:0 50%;transform:scaleX(0)}' +
      '.htc-label{display:inline-grid;justify-items:center}' +
      '.htc-label > span{grid-area:1/1}' +
      '.htc-done{display:inline-flex;align-items:center;gap:.4em;opacity:0}' +
      '.htc-done svg{width:1.1em;height:1.1em;overflow:visible}' +
      '.htc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var reduced = function () { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; };

    function setup(el) {
      var d = el.dataset;
      el.classList.add('htc');
      if (el.tagName === 'BUTTON' && !el.getAttribute('type')) el.type = 'button';
      el.style.setProperty('--htc-color', d.color || DEFAULTS.color);
      // The two labels share one grid cell, so the button keeps the wider width.
      var label = document.createElement('span');
      label.className = 'htc-label';
      var idle = document.createElement('span');
      while (el.firstChild) idle.appendChild(el.firstChild);
      var done = document.createElement('span');
      done.className = 'htc-done';
      done.setAttribute('aria-hidden', 'true');
      done.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
      done.appendChild(document.createTextNode(d.done || DEFAULTS.done));
      label.appendChild(idle);
      label.appendChild(done);
      var fill = document.createElement('span');
      fill.className = 'htc-fill';
      fill.setAttribute('aria-hidden', 'true');
      // Screen readers hear the result here.
      var status = document.createElement('span');
      status.className = 'htc-sr';
      status.setAttribute('role', 'status');
      el.appendChild(fill);
      el.appendChild(label);
      el.appendChild(status);
      if (!el.getAttribute('aria-description')) el.setAttribute('aria-description', 'Press and hold to confirm');
      el._htc = {
        state: 'idle', fill: fill, idle: idle, done: done, status: status,
        tick: anime.svg.createDrawable(done.querySelector('path')),
        grow: null, progress: 0
      };
    }

    function press(el) {
      var s = el._htc;
      if (s.state !== 'idle') return;
      s.state = 'holding';
      var duration = +el.dataset.duration || DEFAULTS.duration;
      if (s.drain) s.drain.pause();
      // Carry on from wherever a drained fill had got back to.
      s.grow = anime.animate(s.fill, {
        scaleX: [s.progress, 1],
        duration: duration * (1 - s.progress),
        ease: 'linear',
        onUpdate: function () { s.progress = anime.utils.get(s.fill, 'scaleX', false); },
        onComplete: function () { confirm(el); }
      });
      if (!reduced()) anime.animate(el, { scale: 0.96, duration: 200, ease: 'out(3)' });
    }

    function release(el) {
      var s = el._htc;
      if (s.state !== 'holding') return;
      s.state = 'idle';
      s.grow.pause();
      s.drain = anime.animate(s.fill, {
        scaleX: 0,
        duration: reduced() ? 0 : 200 + 400 * s.progress,
        ease: 'out(3)',
        onUpdate: function () { s.progress = anime.utils.get(s.fill, 'scaleX', false); },
        onComplete: function () { s.progress = 0; }
      });
      if (!reduced()) anime.animate(el, { scale: 1, duration: 500, ease: 'outElastic(1, .5)' });
    }

    function confirm(el) {
      var s = el._htc;
      var d = el.dataset;
      s.state = 'done';
      s.progress = 0;
      var r = reduced();
      anime.utils.set(s.idle, { opacity: 0 });
      anime.utils.set(s.done, { opacity: 1 });
      s.status.textContent = d.done || DEFAULTS.done;
      if (r) anime.utils.set(s.tick, { draw: '0 1' });
      else {
        // A flash of the fill, a pop, and the tick draws itself in.
        anime.animate(s.fill, { opacity: [1, 0.55], duration: 500, ease: 'out(2)' });
        anime.animate(el, { scale: [{ to: 1.06, duration: 160, ease: 'out(3)' }, { to: 1, duration: 600, ease: 'outElastic(1, .45)' }] });
        anime.animate(s.done, { y: ['0.6em', '0em'], duration: 400, ease: 'out(3)' });
        anime.animate(s.tick, { draw: ['0 0', '0 1'], duration: 400, delay: 100, ease: 'out(3)' });
      }
      el.dispatchEvent(new CustomEvent('confirm', { bubbles: true }));
      setTimeout(function () { reset(el); }, +d.reset || DEFAULTS.reset);
    }

    function reset(el) {
      var s = el._htc;
      var dur = reduced() ? 0 : 400;
      anime.animate(s.fill, {
        scaleX: 0, opacity: 1, duration: dur, ease: 'inOut(2)',
        onComplete: function () { s.state = 'idle'; }
      });
      anime.animate(s.done, { opacity: 0, duration: dur / 2 });
      anime.animate(s.idle, { opacity: [0, 1], y: ['-0.6em', '0em'], duration: dur, delay: dur / 2, ease: 'out(3)' });
      s.status.textContent = '';
    }

    function setupEvents(el) {
      el.addEventListener('pointerdown', function (e) {
        if (e.button !== 0) return;
        // Keep the pointer even if it slides off a little while held.
        if (el.setPointerCapture) el.setPointerCapture(e.pointerId);
        press(el);
      });
      ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(function (t) {
        el.addEventListener(t, function () { release(el); });
      });
      el.addEventListener('keydown', function (e) {
        if (e.key !== ' ' && e.key !== 'Enter') return;
        e.preventDefault();
        if (!e.repeat) press(el);
      });
      el.addEventListener('keyup', function (e) { if (e.key === ' ' || e.key === 'Enter') release(el); });
      el.addEventListener('blur', function () { release(el); });
      // A long press shouldn't bring up the context menu on touch screens.
      el.addEventListener('contextmenu', function (e) { if (el._htc.state !== 'idle') e.preventDefault(); });

      if (el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false' && !reduced()) {
        var duration = +el.dataset.duration || DEFAULTS.duration;
        var n = 0;
        var timer = null;
        var hold = function () {
          if (!el.matches(':hover') && el._htc.state === 'idle') {
            // Every other time, let go just short of full.
            var short = n++ % 2 === 0;
            press(el);
            if (short) setTimeout(function () { release(el); }, duration * 0.6);
          }
          timer = setTimeout(hold, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(hold, 500);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-hold-to-confirm]').forEach(function (el) {
        if (el._htc) return;
        setup(el);
        setupEvents(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button id="delete" data-hold-to-confirm data-done="Deleted">Hold to delete</button>
<script>
  document.getElementById("delete").addEventListener("confirm", () => {
    fetch("/account", { method: "DELETE" });
  });
</script>