← E-commerce

Payment Success Flow

A bank card slides in, gives a little tap and is taken away. A spinner turns while the payment goes through, then the ring closes into a green circle, a tick draws itself, and a receipt slides out of a slot below.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-payment-success-flow and the amount in data-amount.
  3. To show it when a real payment goes through, call animacious.paymentSuccess(element).

Options

Add any of these attributes to the same element.

data-amountThe amount paid. Default £89.00.
data-itemThe line on the receipt. Default “Your order”.
data-cardThe card’s last four digits. Default 4242.
data-colorCard colour. Default #6a0dad.
data-success-colorColour of the tick circle. Default #16a34a.
data-waitHow long the spinner turns. Default 1500 ms.
data-loopPlay again every few seconds while in view.

It is 15em square, so size it with font-size. The status line is announced to screen readers, and it fires a paid event as the tick appears.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Payment Success Flow: a bank card slides in, is taken, and a spinner
    // turns while the payment goes through. Then the ring closes into a green
    // circle, a tick draws itself, and a receipt slides out of a slot below.
    // Usage: <div data-payment-success-flow data-amount="£89.00"></div>
    // Optional: data-amount (default "£89.00"), data-item (the receipt line,
    // default "Your order"), data-card (last four digits, default 4242),
    // data-color (card colour, default #6a0dad), data-success-color (default
    // #16a34a), data-wait (how long the spinner turns in ms, default 1500),
    // data-loop (play again every few seconds while in view).
    // Size it with font-size: it is 15em wide and 15em tall.
    // Fires a "paid" event at the tick. Script use:
    // animacious.paymentSuccess(el) plays it from the start.
    var DEFAULTS = { amount: '£89.00', item: 'Your order', card: '4242', color: '#6a0dad', success: '#16a34a', wait: 1500, hold: 2600 };

    var css =
      '.psf{position:relative;display:block;width:15em;height:15em;max-width:100%;font-size:inherit;text-align:center}' +
      '.psf-card{position:absolute;left:50%;top:.6em;width:10em;height:6.3em;margin-left:-5em;border-radius:.7em;padding:.8em 1em;box-sizing:border-box;text-align:left;color:#fff;' +
      'background:linear-gradient(135deg,rgba(255,255,255,.25),rgba(255,255,255,0) 45%),var(--psf-c);box-shadow:0 .6em 1.4em rgba(15,15,30,.25);opacity:0}' +
      '.psf-chip{display:block;width:1.5em;height:1.1em;border-radius:.22em;background:linear-gradient(135deg,#fde68a,#d4a72c);box-shadow:inset 0 0 0 1px rgba(0,0,0,.15)}' +
      '.psf-num{position:absolute;left:1em;bottom:1.7em;font-size:.82em;letter-spacing:.12em;font-family:ui-monospace,Menlo,Consolas,monospace;opacity:.95}' +
      '.psf-name{position:absolute;left:1.2em;bottom:.9em;font-size:.55em;letter-spacing:.14em;text-transform:uppercase;opacity:.75}' +
      '.psf-logo{position:absolute;right:.9em;bottom:.8em;width:2em;height:1.2em}' +
      '.psf-logo i{position:absolute;top:0;width:1.2em;height:1.2em;border-radius:50%;background:#f5c542;opacity:.9}.psf-logo i+i{right:0;background:#f97316;mix-blend-mode:screen}' +
      '.psf-ring{position:absolute;left:50%;top:1.2em;width:5em;height:5em;margin-left:-2.5em;opacity:0}' +
      '.psf-ring svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.psf-text{position:absolute;left:0;right:0;top:6.9em;display:grid;font-weight:700;line-height:1.25}' +
      '.psf-text>span{grid-area:1/1;opacity:0}' +
      '.psf-text small{display:block;font-weight:500;font-size:.8em;color:#6b6b80}' +
      '.psf-slot{position:absolute;left:50%;top:9.75em;width:11em;height:.5em;margin-left:-5.5em;border-radius:999px;background:#1a1a2e;opacity:0;z-index:2}' +
      '.psf-win{position:absolute;left:50%;top:10em;width:9.6em;margin-left:-4.8em;height:5em;overflow:hidden}' +
      '.psf-receipt{padding:.6em .8em .5em;background:#fff;color:#1a1a2e;font-size:.72em;text-align:left;font-family:ui-monospace,Menlo,Consolas,monospace;' +
      'box-shadow:0 .3em .8em rgba(15,15,30,.12);transform:translateY(-102%)}' +
      '.psf-receipt p{display:flex;justify-content:space-between;gap:.8em;margin:0 0 .3em;white-space:nowrap}' +
      '.psf-receipt p span:first-child{overflow:hidden;text-overflow:ellipsis}' +
      '.psf-receipt hr{border:0;border-top:1px dashed #b8b8c8;margin:.35em 0}' +
      '.psf-receipt .psf-total{font-weight:700;margin:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var R = 34, C = 2 * Math.PI * R;

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function esc(t) { return String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;'); }

    function setup(el) {
      var d = el.dataset;
      var amount = esc(d.amount || DEFAULTS.amount), ok = d.successColor || DEFAULTS.success;
      el.classList.add('psf');
      el.style.setProperty('--psf-c', d.color || DEFAULTS.color);
      el.innerHTML =
        '<div class="psf-card" aria-hidden="true"><span class="psf-chip"></span><span class="psf-num">•••• •••• •••• ' + esc(d.card || DEFAULTS.card) + '</span>' +
        '<span class="psf-name">A. Customer</span><span class="psf-logo"><i></i><i></i></span></div>' +
        '<div class="psf-ring" aria-hidden="true"><svg viewBox="-40 -40 80 80">' +
        '<circle r="' + R + '" fill="none" stroke="#e7e3ef" stroke-width="5"/>' +
        '<circle class="psf-fill" r="' + R + '" fill="' + ok + '" opacity="0"/>' +
        '<circle class="psf-arc" r="' + R + '" fill="none" stroke="' + ok + '" stroke-width="5" stroke-linecap="round" ' +
        'stroke-dasharray="' + C + '" stroke-dashoffset="' + (C * 0.72) + '" transform="rotate(-90)"/>' +
        '<path class="psf-tick" d="M-14 1l9 9 19-20" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="44" stroke-dashoffset="44"/>' +
        '</svg></div>' +
        '<div class="psf-text" role="status"><span class="psf-wait">Processing&hellip;<small>Please wait</small></span>' +
        '<span class="psf-done">Payment successful<small>' + amount + ' paid</small></span></div>' +
        '<span class="psf-slot" aria-hidden="true"></span>' +
        '<div class="psf-win" aria-hidden="true"><div class="psf-receipt">' +
        '<p><span>' + esc(d.item || DEFAULTS.item) + '</span><span>' + amount + '</span></p>' +
        '<p><span>Card •' + esc((d.card || DEFAULTS.card).slice(-4)) + '</span><span>Approved</span></p><hr>' +
        '<p class="psf-total"><span>Total</span><span>' + amount + '</span></p></div></div>';
      var q = function (c) { return el.querySelector(c); };
      var s = el._psf = {
        card: q('.psf-card'), ring: q('.psf-ring'), arc: q('.psf-arc'), fill: q('.psf-fill'), tick: q('.psf-tick'),
        wait: q('.psf-wait'), done: q('.psf-done'), slot: q('.psf-slot'), receipt: q('.psf-receipt'),
        busy: false, visible: false
      };
      if (reduced()) { finished(s); return; }
      new IntersectionObserver(function (entries) {
        s.visible = entries[0].isIntersecting;
        if (s.visible && !s.busy && (!s.played || s.waiting)) { s.waiting = false; play(el); }
      }, { threshold: 0.3 }).observe(el);
    }

    function finished(s) {
      anime.utils.set(s.card, { opacity: 0 });
      anime.utils.set(s.ring, { opacity: 1 });
      anime.utils.set(s.arc, { strokeDashoffset: 0 });
      anime.utils.set(s.fill, { opacity: 1 });
      anime.utils.set(s.tick, { strokeDashoffset: 0 });
      anime.utils.set(s.done, { opacity: 1 });
      anime.utils.set(s.slot, { opacity: 1 });
      anime.utils.set(s.receipt, { y: 0 });
    }

    function play(el) {
      var s = el._psf, d = el.dataset;
      if (s.busy) return;
      s.busy = true;
      s.played = true;
      var wait = +d.wait || DEFAULTS.wait;
      // Back to the start.
      anime.utils.set(s.card, { x: '-15em', y: 0, rotate: -10, scale: 1, opacity: 0 });
      anime.utils.set(s.ring, { opacity: 0, scale: 0.6, rotate: 0 });
      anime.utils.set(s.arc, { strokeDashoffset: C * 0.72 });
      anime.utils.set(s.fill, { opacity: 0, scale: 0.4 });
      anime.utils.set(s.tick, { strokeDashoffset: 44 });
      anime.utils.set([s.wait, s.done], { opacity: 0, y: 0 });
      anime.utils.set(s.slot, { opacity: 0, scaleX: 0.3 });
      anime.utils.set(s.receipt, { y: '-102%' });

      var t1 = 1300, t2 = t1 + 300 + wait;
      var spin = null;
      var tl = anime.createTimeline({
        onComplete: function () {
          s.busy = false;
          if (!on(d.loop)) return;
          setTimeout(function () {
            if (s.visible) anime.animate(el.children, { opacity: [1, 0], duration: 350, onComplete: function () {
              anime.utils.set(el.children, { opacity: 1 });
              play(el);
            } });
            else s.waiting = true;
          }, DEFAULTS.hold);
        }
      });
      // The card slides in, straightens, gives a little "tap" and is taken away.
      tl.add(s.card, { x: ['-15em', '0em'], rotate: [-10, 0], opacity: [0, 1], duration: 750, ease: 'out(4)' }, 0)
        .add(s.card, { scale: [1, 0.94, 1], duration: 260, ease: 'inOut(2)' }, 850)
        .add(s.card, { y: '-1em', scale: 0.6, opacity: 0, duration: 380, ease: 'in(2)' }, t1 - 150)
        // Spinner.
        .add(s.ring, { opacity: [0, 1], scale: [0.6, 1], duration: 360, ease: 'outBack(1.6)' }, t1)
        .add(s.wait, { opacity: [0, 1], y: ['.4em', 0], duration: 300, ease: 'out(2)' }, t1 + 60)
        .call(function () {
          spin = anime.animate(s.ring, { rotate: '+=360', duration: 900, ease: 'linear', loop: true });
        }, t1)
        .call(function () {
          if (spin) spin.pause();
          anime.animate(s.ring, { rotate: Math.ceil(anime.utils.get(s.ring, 'rotate', false) / 360) * 360, duration: 420, ease: 'out(2)' });
        }, t2)
        // The ring closes, fills green and the tick draws in.
        .add(s.arc, { strokeDashoffset: 0, duration: 420, ease: 'out(2)' }, t2)
        .add(s.fill, { opacity: [0, 1], scale: [0.4, 1], duration: 420, ease: 'outBack(2)' }, t2 + 340)
        .add(s.ring, { scale: [1, 1.12, 1], duration: 420, ease: 'out(2)' }, t2 + 380)
        .add(s.tick, { strokeDashoffset: [44, 0], duration: 380, ease: 'out(3)' }, t2 + 560)
        .add(s.wait, { opacity: 0, y: '-.4em', duration: 220, ease: 'in(2)' }, t2 + 300)
        .add(s.done, { opacity: [0, 1], y: ['.4em', 0], duration: 360, ease: 'out(3)' }, t2 + 480)
        .call(function () { el.dispatchEvent(new CustomEvent('paid', { bubbles: true })); }, t2 + 560)
        // The receipt slides out of its slot.
        .add(s.slot, { opacity: [0, 1], scaleX: [0.3, 1], duration: 320, ease: 'out(3)' }, t2 + 900)
        .add(s.receipt, { y: ['-102%', '0%'], duration: 1100, ease: 'out(2)' }, t2 + 1150);
    }

    window.animacious = window.animacious || {};
    window.animacious.paymentSuccess = function (el) { if (el && el._psf) play(el); };

    function init() {
      document.querySelectorAll('[data-payment-success-flow]').forEach(function (el) {
        if (el._psf) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-payment-success-flow data-amount="£89.00" data-item="Trail Runner"></div>