Hold to confirm button in plain JavaScript

Press and hold for one second to confirm. A filling ring shows progress, letting go early cancels, and keyboards work too.

"Are you sure?" dialogs get clicked through without being read. A hold to confirm button asks for something a reflex cannot give: one full second of intent. Press and hold, watch the ring fill, and the action fires. Let go early and nothing happens.

Press and hold for one second. On a keyboard, hold Space or Enter.

Live demo. It runs on exactly the code further down this page.

How it works

  1. Start the clock. On pointerdown the script notes the time and captures the pointer, so sliding a finger slightly off the button does not cancel the hold.
  2. Draw the progress. Each animation frame sets a CSS custom property, --p, from 0 to 1. The ring is a conic-gradient that reads that value, masked into a circle. No SVG, no canvas.
  3. Fire once. When the hold passes one second, the action runs and the button turns into a done state. Keep holding and it starts to rattle, a small reward for the stubborn.
  4. Cancel cleanly. Releasing early, moving focus away, or the browser cancelling the pointer all reset the ring and say so in a live region.
  5. Keyboard too. Holding Space or Enter does the same thing. The script ignores the key repeat events the operating system sends while a key is held.

The code

HTML
<div class="hc" data-lab="hold-confirm">
  <button class="hc-btn" type="button" aria-describedby="hc-help">
    <span class="hc-ring" aria-hidden="true"></span>
    <span class="hc-label">Hold to delete</span>
  </button>
  <p id="hc-help" class="hc-help">Press and hold for one second. On a keyboard, hold Space or Enter.</p>
  <p class="hc-status" aria-live="polite"></p>
</div>
CSS
.hc {
  display: grid;
  justify-items: center;
  gap: 12px;
  font-family: system-ui, sans-serif;
}
.hc-btn {
  --p: 0;
  display: inline-flex;
  align-items: center;
  gap: 12px;
  height: 56px;
  padding: 0 26px 0 14px;
  border: 0;
  border-radius: 999px;
  background: #133621;
  color: #f4d96c;
  font: 600 16px/1 system-ui, sans-serif;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
  transition: background-color .3s;
}
.hc-btn:focus-visible { outline: 3px solid #f4d96c; outline-offset: 3px; }
/* A ring that fills with the hold progress, drawn with a conic gradient. */
.hc-ring {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: conic-gradient(#f4d96c calc(var(--p) * 1turn), rgba(244, 217, 108, .22) 0);
  -webkit-mask: radial-gradient(circle, transparent 9px, #000 10px);
  mask: radial-gradient(circle, transparent 9px, #000 10px);
}
.hc-btn.is-done { background: #2f6b45; }
.hc-btn.is-shaking { animation: hc-shake .12s linear infinite; }
@keyframes hc-shake {
  25% { transform: translate(-1px, 1px) rotate(-1deg); }
  75% { transform: translate(1px, -1px) rotate(1deg); }
}
.hc-help { margin: 0; font-size: 14px; color: #46534a; text-align: center; }
.hc-status { min-height: 1.4em; margin: 0; font-size: 15px; font-weight: 600; color: #133621; text-align: center; }
@media (prefers-reduced-motion: reduce) { .hc-btn.is-shaking { animation: none; } }
JavaScript
(function () {
  var root = document.querySelector('[data-lab="hold-confirm"]');
  if (!root) return;
  var btn = root.querySelector('.hc-btn');
  var label = root.querySelector('.hc-label');
  var status = root.querySelector('.hc-status');
  var HOLD = 1000;          // milliseconds the reader has to hold
  var start = 0, raf = 0, holding = false, done = false;

  function setProgress(p) { btn.style.setProperty('--p', p); }

  function frame(t) {
    if (!holding) return;
    var held = t - start;
    setProgress(Math.min(1, held / HOLD));
    if (held >= HOLD && !done) confirmAction();
    // Keep holding after it fired and the button starts to rattle.
    btn.classList.toggle('is-shaking', done && held > HOLD + 600);
    raf = requestAnimationFrame(frame);
  }

  function begin() {
    if (holding) return;
    if (done) reset();
    holding = true;
    start = performance.now();
    status.textContent = '';
    raf = requestAnimationFrame(frame);
  }

  function end() {
    if (!holding) return;
    holding = false;
    cancelAnimationFrame(raf);
    btn.classList.remove('is-shaking');
    if (!done) {
      setProgress(0);
      status.textContent = 'Released too early. Nothing happened.';
    }
  }

  function confirmAction() {
    done = true;
    btn.classList.add('is-done');
    label.textContent = 'Deleted';
    status.textContent = 'Confirmed. This is a demo, so nothing was deleted.';
  }

  function reset() {
    done = false;
    btn.classList.remove('is-done');
    label.textContent = 'Hold to delete';
    setProgress(0);
  }

  btn.addEventListener('pointerdown', function (e) {
    btn.setPointerCapture(e.pointerId);
    begin();
  });
  btn.addEventListener('pointerup', end);
  btn.addEventListener('pointercancel', end);
  btn.addEventListener('keydown', function (e) {
    if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) { e.preventDefault(); begin(); }
  });
  btn.addEventListener('keyup', function (e) {
    if (e.key === ' ' || e.key === 'Enter') end();
  });
  btn.addEventListener('blur', end);
  btn.addEventListener('contextmenu', function (e) { e.preventDefault(); });
})();

When to use it, and when not to

It suits actions that are easy to trigger by accident and annoying to undo: deleting a draft, clearing a cart, ending a live session, cancelling an order in a staff tool. It is also handy on touch screens in shops and kitchens, where a stray tap is common.

It is the wrong choice for anything people do many times a day, because a second of waiting adds up fast. Where an undo is possible, an undo is still kinder than any confirmation. And holding can be hard for people with tremors or limited grip, so keep a second way to do the same thing, such as a menu option with a normal confirm step.

Make it yours

  • Hold time: change HOLD. Between 600 and 1500 milliseconds feels intentional without being tiresome.
  • The action: put your real code inside confirmAction(), such as a fetch call or a form submit.
  • Look: the ring size is the .hc-ring width and the mask radius; the colours are two hex values in the gradient.

Frequently asked questions

Does it work on phones?

Yes. It uses pointer events, which cover mouse, touch and pen, and turns off text selection and the long press menu so the hold is not interrupted.

What do screen reader users hear?

The button points to its instructions with aria-describedby, and every outcome, early release or confirmed, is announced from a live region.

Can I use it inside a form?

Yes. It is type="button", so it never submits on its own; call form.requestSubmit() inside confirmAction() if you want it to.

What should your website do?

Describe a feature you wish your site had. If it is useful to other owners too, we build it here as a free Lab post with a live demo and code.

We never publish your name, email or website. Privacy

More from the Website Lab

All experiments