"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.
How it works
- Start the clock. On
pointerdownthe script notes the time and captures the pointer, so sliding a finger slightly off the button does not cancel the hold. - Draw the progress. Each animation frame sets a CSS custom property,
--p, from 0 to 1. The ring is aconic-gradientthat reads that value, masked into a circle. No SVG, no canvas. - 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.
- Cancel cleanly. Releasing early, moving focus away, or the browser cancelling the pointer all reset the ring and say so in a live region.
- 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
<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>.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; } }(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 afetchcall or a form submit. - Look: the ring size is the
.hc-ringwidth 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.


