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
- Paste the snippet just before
</head>. - Add
data-hold-to-confirmto a button. - Do the real thing when it fires its
confirmevent.
Options
Add any of these attributes to the same element.
data-duration | How long to hold. Default 1400 ms. |
data-done | Label once confirmed. Default “Done”. |
data-color | The fill colour. Default white at 30%. |
data-reset | How long before it’s ready again. Default 1800 ms. |
data-autoplay | It 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.
<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><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>