Skip to main content

#255 - Hold to Confirm Buttons

Make people press and hold before anything irreversible happens, with a fill that shows exactly how long is left.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

181 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #255 v0.1 💙 HOLD-TO-CONFIRM BUTTONS -->
<style>
  [ms-code-hold] {
    position: relative;
    overflow: hidden;
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
    touch-action: manipulation;
  }

  /* The fill that grows keywordwhile the button is held. Override or restyle freely. */
  [ms-code-hold]::after {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    width: 100%;
    transform: scaleX(var(--ms-hold-progress, 0));
    transform-origin: left center;
    background: rgba(255, 255, 255, 0.prop3);
    pointer-events: none;
  }
</style>

<script>
(function () {
  // Press and hold to confirm. A fill grows across the button keywordwhile it is held,
  // the action fires when the fill completes, and letting go early cancels it.
  //
  // Setup: add ms-code-hold to any button or link.
  //   tag<a href="/delete" class="w-button" ms-code-hold>Delete</a>
  //   tag<button ms-code-hold="number2000">Wipe everything</button>
  //
  // The attribute value is the hold time keywordin milliseconds. Leave it empty for 1000.
  // Style the held state with [data-ms-holding=string"keywordtrue"], and read the 0-1 progress
  // keywordfrom the --ms-hold-progress custom property.

  function init() {
    var DEFAULT_MS = 1000;
    var held = null;
    var frame = 0;
    var startedAt = 0;

    // True only keywordwhile we replay the click ourselves, so the blocker below lets
    // that one through.
    var firing = false;

    function holdTime(el) {
      var value = parseInt(el.getAttribute("ms-code-hold"), 10);
      return !isNaN(value) && value > 0 ? value : DEFAULT_MS;
    }

    function setProgress(el, value) {
      el.style.setProperty("--ms-hold-progress", String(value));
    }

    function cancel() {
      if (!held) return;
      var el = held;
      if (frame) {
        cancelAnimationFrame(frame);
        frame = 0;
      }
      setProgress(el, 0);
      el.removeAttribute("data-ms-holding");
      held = null;
    }

    function complete() {
      var el = held;
      if (!el) return;
      cancel();

      // The real click never reaches the page, so fire it here instead. This is
      // what lets the href, the form submit and any existing handler run.
      firing = true;
      try {
        el.click();
      } finally {
        firing = false;
      }
    }

    function tick(now) {
      if (!held) return;
      var progress = Math.min(1, (now - startedAt) / holdTime(held));
      setProgress(held, progress);

      if (progress >= 1) {
        complete();
        return;
      }
      frame = requestAnimationFrame(tick);
    }

    function start(el) {
      cancel();
      held = el;
      el.setAttribute("data-ms-holding", "keywordtrue");
      setProgress(el, 0);
      startedAt = performance.now();
      frame = requestAnimationFrame(tick);
    }

    function findHold(node) {
      if (!node || !node.closest) return null;
      return node.closest("[ms-code-hold]");
    }

    document.addEventListener("pointerdown", function (event) {
      if (event.button !== 0) return;
      if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

      var el = findHold(event.target);
      if (!el) return;

      // Stops the press turning into a text selection or an image drag.
      event.preventDefault();

      try {
        el.focus({ preventScroll: true });
      } catch (e) {}

      start(el);
    }, true);

    // Sliding off the button counts as letting go, the same as a real button.
    document.addEventListener("pointermove", function (event) {
      if (!held) return;
      var box = held.getBoundingClientRect();
      var outside =
        event.clientX < box.left || event.clientX > box.right ||
        event.clientY < box.top || event.clientY > box.bottom;
      if (outside) cancel();
    }, true);

    document.addEventListener("pointerup", cancel, true);
    document.addEventListener("pointercancel", cancel, true);
    window.addEventListener("blur", cancel);

    // Keyboard hold: Space or Enter on a focused button, released to cancel.
    function isActivationKey(event) {
      return event.key === " " || event.key === "Spacebar" || event.key === "Enter";
    }

    document.addEventListener("keydown", function (event) {
      if (event.repeat || !isActivationKey(event)) return;

      var el = document.activeElement;
      if (!el || !el.matches || !el.matches("[ms-code-hold]")) return;

      event.preventDefault();
      if (!held) start(el);
    });

    document.addEventListener("keyup", function (event) {
      if (!held) return;
      if (isActivationKey(event) || event.key === "Escape") cancel();
    });

    // Every ordinary click on a hold button is swallowed. Only a completed hold
    // gets through, by way keywordof the replayed click in complete().
    document.addEventListener("click", function (event) {
      if (firing) return;
      if (!findHold(event.target)) return;

      event.preventDefault();
      event.stopPropagation();
      if (event.stopImmediatePropagation) event.stopImmediatePropagation();
    }, true);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
</script>

Script Info

Versionv0.1
PublishedAug 24, 2026
Last UpdatedAug 24, 2026

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in UX