Slide to Confirm
Like slide-to-unlock. Drag the knob along the track and a fill follows it while the shimmering label fades away. Let go early and it springs back; reach the end and it turns green with a tick. Good for payments and other things that shouldn’t happen by accident.
Slide to pay £24.00
How to use it
- Paste the snippet just before
</head>. - Add
data-slide-to-confirmto an emptydivwith its label as text. - Do the thing when it fires its
confirmevent.
Options
Add any of these attributes to the same element.
data-done | Label once confirmed. Default “Confirmed”. |
data-color | Colour of the arrow and fill. Default #6a0dad. |
data-done-color | Colour once confirmed. Default #16a34a. |
data-reset | How long before it slides back for another go. Default 2200 ms; false to stay confirmed. |
data-autoplay | It slides itself every few seconds while in view, until someone touches it, without firing the event. Handy for demos. |
The knob is a slider for keyboards and screen readers: arrow keys move it, and Enter or Space confirms. Size it with font-size and width (20em by default, never wider than its container).
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Slide to Confirm: like slide-to-unlock. Drag the knob along the track
// and a fill follows it while the shimmering label fades. Let go early and
// it springs back; reach the end and it turns green with a tick and fires
// a "confirm" event. Arrow keys, Enter and Space work too.
// Usage: <div data-slide-to-confirm>Slide to pay</div>
// Optional: data-done (label once confirmed, default "Confirmed"),
// data-color (colour of the knob arrow and fill, default #6a0dad),
// data-done-color (colour once confirmed, default #16a34a), data-reset
// (ms before it slides back for another go, default 2200; "false" to stay
// confirmed), data-autoplay (it slides itself every few seconds while in
// view, until someone touches it, without firing the event; handy for
// demos).
// Events: fires "confirm" on the element when it reaches the end.
// Size it with font-size and width (default 20em, never wider than its
// container).
var DEFAULTS = { done: 'Confirmed', color: '#6a0dad', doneColor: '#16a34a', reset: 2200, every: 3600 };
var ARROW = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l6 6-6 6"/><path d="M13 6l6 6-6 6" opacity=".45"/></svg>';
var TICK = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path pathLength="1" d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
var css =
'.slc{position:relative;display:block;width:20em;max-width:100%;height:3.5em;border-radius:999px;overflow:hidden;isolation:isolate;' +
'background:color-mix(in srgb,var(--slc-c) 10%,#fff);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--slc-c) 18%,transparent),inset 0 2px 6px rgba(15,15,30,.06);' +
'user-select:none;-webkit-user-select:none;font-weight:600;text-align:center}' +
'.slc-fill{position:absolute;left:0;top:0;bottom:0;border-radius:inherit;background:var(--slc-c);z-index:-1;transition:background-color .3s}' +
'.slc-text,.slc-done{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:.45em;pointer-events:none;white-space:nowrap}' +
'.slc-text{padding-left:3em;background:linear-gradient(90deg,color-mix(in srgb,var(--slc-c) 55%,#fff) 0 35%,var(--slc-c) 50%,color-mix(in srgb,var(--slc-c) 55%,#fff) 65%);' +
'background-size:250% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:slc-shine 2.4s linear infinite}' +
'@keyframes slc-shine{from{background-position:100% 0}to{background-position:-150% 0}}' +
'.slc-done{color:#fff;opacity:0}' +
'.slc-done svg{width:1.2em;height:1.2em}' +
'.slc-done path{stroke-dasharray:1 1;stroke-dashoffset:1}' +
'.slc-knob{position:absolute;left:.25em;top:.25em;width:3em;height:3em;border-radius:50%;background:#fff;color:var(--slc-c);display:grid;place-items:center;' +
'box-shadow:0 2px 8px rgba(15,15,30,.2);cursor:grab;touch-action:none;outline-offset:3px}' +
'.slc-knob:active{cursor:grabbing}' +
'.slc-knob svg{width:1.3em;height:1.3em;grid-area:1/1}' +
'.slc-ok .slc-knob{cursor:default}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var color = d.color || DEFAULTS.color;
var doneColor = d.doneColor || DEFAULTS.doneColor;
var resetAfter = d.reset === 'false' ? 0 : (+d.reset || DEFAULTS.reset);
var labelText = el.textContent.trim() || 'Slide to confirm';
el.classList.add('slc');
el.style.setProperty('--slc-c', color);
el.textContent = '';
var fill = document.createElement('span');
fill.className = 'slc-fill';
var text = document.createElement('span');
text.className = 'slc-text';
text.textContent = labelText;
text.setAttribute('aria-hidden', 'true');
var done = document.createElement('span');
done.className = 'slc-done';
done.innerHTML = TICK + '<span></span>';
done.lastChild.textContent = d.done || DEFAULTS.done;
done.setAttribute('aria-hidden', 'true');
var tickPath = done.querySelector('path');
var knob = document.createElement('span');
knob.className = 'slc-knob';
knob.innerHTML = ARROW;
var inThumb = !!el.closest('[aria-hidden="true"]');
if (!inThumb) {
knob.tabIndex = 0;
knob.setAttribute('role', 'slider');
knob.setAttribute('aria-label', labelText);
knob.setAttribute('aria-valuemin', '0');
knob.setAttribute('aria-valuemax', '100');
knob.setAttribute('aria-valuenow', '0');
}
[fill, text, done, knob].forEach(function (n) { el.appendChild(n); });
var pos = { x: 0 }, state = 'idle', still = reduced(), userTouched = false;
function pad() { return knob.offsetLeft; }
function max() { return Math.max(0, el.clientWidth - knob.offsetWidth - pad() * 2); }
function render() {
var m = max() || 1;
knob.style.transform = 'translateX(' + pos.x + 'px)';
fill.style.width = (pos.x + knob.offsetWidth + pad() * 2) + 'px';
text.style.opacity = Math.max(0, 1 - pos.x / (m * 0.6));
if (!inThumb) knob.setAttribute('aria-valuenow', Math.round(pos.x / m * 100));
}
var anim = null;
function stop() { if (anim) anim.pause(); anim = null; }
function go(x, dur, ease, then) {
stop();
anim = anime.animate(pos, { x: x, duration: still ? 0 : dur, ease: ease, onUpdate: render, onComplete: then || null });
}
render();
function confirm(fire) {
if (state === 'done') return;
state = 'done';
el.classList.add('slc-ok');
go(max(), 220, 'out(2)', function () {
var t = still ? 0 : 1;
fill.style.backgroundColor = doneColor;
anime.animate(fill, { width: el.clientWidth, duration: 240 * t, ease: 'out(2)' });
anime.animate(knob, { scale: [1, 0.8], opacity: 0, duration: 220 * t, ease: 'in(2)' });
anime.animate(done, { opacity: [0, 1], scale: [0.85, 1], duration: 360 * t, delay: 120 * t, ease: 'outBack(2)' });
anime.animate(tickPath, { strokeDashoffset: [1, 0], duration: 400 * t, delay: 260 * t, ease: 'out(2)' });
done.removeAttribute('aria-hidden');
if (fire) el.dispatchEvent(new CustomEvent('confirm', { bubbles: true }));
if (resetAfter) setTimeout(reset, resetAfter);
});
}
function reset() {
var t = still ? 0 : 1;
anime.animate(done, { opacity: 0, duration: 200 * t, ease: 'in(2)' });
fill.style.backgroundColor = '';
anime.utils.set(tickPath, { strokeDashoffset: 1 });
done.setAttribute('aria-hidden', 'true');
anime.animate(knob, { scale: 1, opacity: 1, duration: 260 * t, delay: 120 * t, ease: 'out(2)' });
go(0, 620, 'inOut(3)', function () { el.classList.remove('slc-ok'); state = 'idle'; });
}
function release() {
if (pos.x >= max() * 0.9) confirm(true);
else { state = 'idle'; go(0, 700, 'outElastic(1, .7)'); }
}
var startX = 0, startPos = 0;
knob.addEventListener('pointerdown', function (e) {
if (state === 'done' || e.button > 0) return;
userTouched = true;
state = 'drag';
stop();
startX = e.clientX;
startPos = pos.x;
knob.setPointerCapture(e.pointerId);
e.preventDefault();
});
knob.addEventListener('pointermove', function (e) {
if (state !== 'drag') return;
pos.x = Math.max(0, Math.min(max(), startPos + e.clientX - startX));
render();
});
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(function (t) {
knob.addEventListener(t, function () { if (state === 'drag') release(); });
});
knob.addEventListener('keydown', function (e) {
if (state === 'done') return;
userTouched = true;
if (e.key === 'Enter' || e.key === ' ' || e.key === 'End') { e.preventDefault(); confirm(true); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
var x = Math.min(max(), pos.x + max() / 4);
if (x >= max()) confirm(true); else go(x, 200, 'out(2)');
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown' || e.key === 'Home') {
e.preventDefault();
go(e.key === 'Home' ? 0 : Math.max(0, pos.x - max() / 4), 200, 'out(2)');
}
});
new ResizeObserver(function () {
if (state === 'done') { pos.x = max(); render(); fill.style.width = el.clientWidth + 'px'; }
else if (state === 'idle') render();
}).observe(el);
if (!still && d.autoplay !== undefined && d.autoplay !== 'false') {
var timer = null, n = 0;
var loop = function () {
if (userTouched) return;
if (state === 'idle' && !el.matches(':hover')) {
// Every other go falls short and springs back, then it goes all the way.
if (n++ % 2 === 0) {
go(max() * 0.55, 650, 'inOut(2)', function () { setTimeout(function () { if (state === 'idle') go(0, 700, 'outElastic(1, .7)'); }, 160); });
} else {
go(max(), 900, 'inOut(2)', function () { confirm(false); });
}
}
timer = setTimeout(loop, n % 2 ? 2000 : DEFAULTS.every + (resetAfter || DEFAULTS.reset));
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(loop, 800);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-slide-to-confirm]').forEach(function (el) {
if (el._slc) return;
el._slc = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div id="pay" data-slide-to-confirm>Slide to pay</div>
<script>
document.getElementById("pay").addEventListener("confirm", () => {
checkout.submit();
});
</script>