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
- Paste the snippet just before
</head>. - Add an empty element with
data-payment-success-flowand the amount indata-amount. - To show it when a real payment goes through, call
animacious.paymentSuccess(element).
Options
Add any of these attributes to the same element.
data-amount | The amount paid. Default £89.00. |
data-item | The line on the receipt. Default “Your order”. |
data-card | The card’s last four digits. Default 4242. |
data-color | Card colour. Default #6a0dad. |
data-success-color | Colour of the tick circle. Default #16a34a. |
data-wait | How long the spinner turns. Default 1500 ms. |
data-loop | Play 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.
<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, '&').replace(/</g, '<'); }
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…<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><div data-payment-success-flow data-amount="£89.00" data-item="Trail Runner"></div>