← Buttons

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

  1. Paste the snippet just before </head>.
  2. Add data-slide-to-confirm to an empty div with its label as text.
  3. Do the thing when it fires its confirm event.

Options

Add any of these attributes to the same element.

data-doneLabel once confirmed. Default “Confirmed”.
data-colorColour of the arrow and fill. Default #6a0dad.
data-done-colorColour once confirmed. Default #16a34a.
data-resetHow long before it slides back for another go. Default 2200 ms; false to stay confirmed.
data-autoplayIt 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).

1 · Snippet
<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>
2 · Your HTML
<div id="pay" data-slide-to-confirm>Slide to pay</div>
<script>
  document.getElementById("pay").addEventListener("confirm", () => {
    checkout.submit();
  });
</script>