Free Shipping Progress
A bar fills towards the free-delivery threshold with a little van riding along its end, wheels turning. The message above counts down what’s left to spend; reach the threshold and the bar turns green, the flag at the end waves and the van hops.
How to use it
- Paste the snippet just before
</head>. - Add an empty element with
data-free-shipping-progress, the basket total indata-totaland the threshold indata-threshold. - When the basket changes, update
data-total(or callanimacious.freeShipping(element, 41)) and the van drives to the new spot.
Options
Add any of these attributes to the same element.
data-total | The basket total so far. |
data-threshold | What the basket must reach for free delivery. Default 50. |
data-currency | Currency symbol. Default £. |
data-color | Bar and van colour. Default #6a0dad. |
data-done-color | Bar colour once free delivery is unlocked. Default #16a34a. |
data-steps | A list of totals to play through, e.g. 14,31,44,56. Handy for demos. |
data-loop | With data-steps: start again at the end. |
It is a progressbar for screen readers, and the message is announced as it changes.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Free Shipping Progress: a bar fills towards the free-delivery threshold
// with a little delivery van riding along the end of it, wheels turning.
// The message above counts down what's left to spend; reach the threshold
// and the bar turns green, the flag at the end waves and the van hops.
// Usage:
// <div data-free-shipping-progress data-total="32" data-threshold="50"></div>
// Change data-total (or call animacious.freeShipping(el, 41)) whenever the
// basket changes and the van drives to the new spot.
// Optional: data-threshold (default 50), data-currency (default "£"),
// data-color (bar colour, default #6a0dad), data-done-color (bar colour
// once unlocked, default #16a34a), data-steps (a list of basket totals to
// play through, e.g. "14,31,44,56", with data-loop starting again at the
// end; handy for demos).
var DEFAULTS = { threshold: 50, currency: '£', color: '#6a0dad', done: '#16a34a', step: 1700 };
var css =
'.fsp{width:100%;max-width:30em;text-align:left;font-variant-numeric:tabular-nums}' +
'.fsp-msg{position:relative;display:grid;min-height:1.45em;margin-bottom:.5em;line-height:1.45}' +
'.fsp-msg>span{grid-area:1/1;display:flex;align-items:center;gap:.35em}' +
'.fsp-msg b{color:var(--fsp-color)}' +
'.fsp-msg svg{width:1.1em;height:1.1em;flex:none;color:var(--fsp-done)}' +
'.fsp-road{position:relative;height:2.5em}' +
'.fsp-track{position:absolute;left:0;right:1.7em;bottom:0;height:.5em;border-radius:99px;background:rgba(15,15,30,.09);overflow:hidden}' +
'.fsp-fill{height:100%;width:0;border-radius:inherit;background:var(--fsp-color)}' +
'.fsp-van{position:absolute;left:0;bottom:.62em;width:2.3em;height:1.45em;color:var(--fsp-color)}' +
'.fsp-van svg{display:block;width:100%;height:100%;overflow:visible}' +
'.fsp-wheel{transform-box:fill-box;transform-origin:center}' +
'.fsp-goal{position:absolute;right:0;bottom:0;width:1.3em;height:2em;color:var(--fsp-done)}' +
'.fsp-goal svg{display:block;width:100%;height:100%;overflow:visible}' +
'.fsp-flag{transform-box:fill-box;transform-origin:0 50%}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var VAN = '<svg viewBox="0 0 46 29" aria-hidden="true">' +
'<path d="M2 4a3 3 0 0 1 3-3h22a3 3 0 0 1 3 3v19H2z" fill="currentColor"/>' +
'<path d="M30 8h8.5a3 3 0 0 1 2.6 1.5L45 16v7H30z" fill="currentColor"/>' +
'<path d="M33 11h5l3 5h-8z" fill="#fff" opacity=".85"/>' +
'<path d="M7 9h14M7 14h9" stroke="#fff" stroke-width="2.2" stroke-linecap="round" opacity=".6"/>' +
'<g class="fsp-wheel"><circle cx="11" cy="24" r="4.3" fill="#1a1a2e" stroke="#fff" stroke-width="1.6"/><path d="M11 21v6" stroke="#fff" stroke-width="1.2"/></g>' +
'<g class="fsp-wheel"><circle cx="37" cy="24" r="4.3" fill="#1a1a2e" stroke="#fff" stroke-width="1.6"/><path d="M37 21v6" stroke="#fff" stroke-width="1.2"/></g>' +
'</svg>';
var GOAL = '<svg viewBox="0 0 20 32" aria-hidden="true">' +
'<path d="M3 2v30" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"/>' +
'<path class="fsp-flag" d="M4 3h13l-3 5 3 5H4z" fill="currentColor"/>' +
'</svg>';
var TICK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="currentColor"/><path d="M7 12.5l3.2 3.2L17 9" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function money(s, n) { return s.currency + (s.whole ? Math.ceil(n - 1e-6) : n).toFixed(s.whole ? 0 : 2); }
function setup(el) {
var d = el.dataset;
el.classList.add('fsp');
el.style.setProperty('--fsp-color', d.color || DEFAULTS.color);
el.style.setProperty('--fsp-done', d.doneColor || DEFAULTS.done);
el.innerHTML =
'<div class="fsp-msg" aria-live="polite"><span></span></div>' +
'<div class="fsp-road" aria-hidden="true"><div class="fsp-track"><div class="fsp-fill"></div></div>' +
'<div class="fsp-van">' + VAN + '</div><div class="fsp-goal">' + GOAL + '</div></div>';
var s = el._fsp = {
msg: el.querySelector('.fsp-msg'), road: el.querySelector('.fsp-road'), track: el.querySelector('.fsp-track'),
fill: el.querySelector('.fsp-fill'), van: el.querySelector('.fsp-van'), flag: el.querySelector('.fsp-flag'),
wheels: el.querySelectorAll('.fsp-wheel'),
threshold: +d.threshold || DEFAULTS.threshold, currency: d.currency !== undefined ? d.currency : DEFAULTS.currency,
shown: { total: 0 }, total: 0, done: null
};
el.setAttribute('role', 'progressbar');
el.setAttribute('aria-valuemin', 0);
el.setAttribute('aria-valuemax', s.threshold);
var steps = (d.steps || '').split(',').map(Number).filter(function (n) { return !isNaN(n) && n >= 0; });
s.steps = steps.length ? steps : null;
s.whole = [s.threshold, +d.total || 0].concat(steps).every(function (n) { return Number.isInteger(n); });
draw(el);
message(el, true);
new ResizeObserver(function () { draw(el); }).observe(s.road);
new MutationObserver(function () { if (s.started) go(el, +el.dataset.total || 0); })
.observe(el, { attributes: true, attributeFilter: ['data-total'] });
}
// Place the fill and the van for the total currently on show.
function draw(el) {
var s = el._fsp;
var p = Math.max(0, Math.min(1, s.shown.total / s.threshold));
var tw = s.track.offsetWidth, vw = s.van.offsetWidth;
s.fill.style.width = p * 100 + '%';
var front = Math.max(vw * 0.9, p * tw + vw * 0.35);
s.van.style.left = Math.min(front, tw + vw * 0.35) - vw + 'px';
var unlocked = s.shown.total >= s.threshold;
s.fill.style.background = s.van.style.color = unlocked ? 'var(--fsp-done)' : '';
}
function message(el, instant) {
var s = el._fsp;
var done = s.shown.total >= s.threshold;
el.setAttribute('aria-valuenow', Math.min(s.total, s.threshold));
var left = s.threshold - s.shown.total;
var html = done
? TICK + '<span>You’ve unlocked <b style="color:var(--fsp-done)">free delivery</b></span>'
: '<span>Spend <b>' + money(s, left) + '</b> more for free delivery</span>';
if (s.done === done && !instant) {
s.msg.lastChild.innerHTML = html;
return;
}
s.done = done;
var old = s.msg.firstChild;
var next = document.createElement('span');
next.innerHTML = html;
s.msg.appendChild(next);
if (instant || reduced()) { if (old !== next) old.remove(); return; }
anime.animate(old, { opacity: 0, y: '-.6em', duration: 250, ease: 'in(2)', onComplete: function () { old.remove(); } });
anime.animate(next, { opacity: [0, 1], y: ['.6em', 0], duration: 420, delay: 120, ease: 'out(3)' });
}
function go(el, total) {
var s = el._fsp;
var was = s.total;
s.total = Math.max(0, total);
if (reduced()) { s.shown.total = s.total; draw(el); message(el, true); return; }
var crossed = was < s.threshold && s.total >= s.threshold;
var dist = Math.abs(Math.min(s.total, s.threshold) - Math.min(was, s.threshold)) / s.threshold;
var dur = 500 + dist * 1100;
var back = s.total < was;
if (s.anim) s.anim.pause();
s.anim = anime.animate(s.shown, {
total: s.total,
duration: dur,
ease: 'inOut(2)',
onUpdate: function () { draw(el); message(el); },
onComplete: function () {
draw(el);
message(el);
if (crossed) celebrate(el);
}
});
// Wheels turn and the body bobs over the bumps while it drives.
anime.animate(s.wheels, { rotate: (back ? '-=' : '+=') + Math.round(dist * 1400 + 120), duration: dur, ease: 'inOut(2)' });
anime.animate(s.van, { y: [0, -1.5, 0, -1, 0], duration: dur, ease: 'linear' });
anime.animate(s.van, { rotate: [0, back ? 4 : -4, 0], duration: dur, ease: 'inOut(2)' });
}
function celebrate(el) {
var s = el._fsp;
anime.animate(s.van, { y: [0, '-.5em', 0], duration: 520, ease: 'out(2)' });
anime.animate(s.flag, { scaleX: [1, -0.6, 1, -0.4, 1], duration: 900, ease: 'inOut(2)' });
anime.animate(s.fill, { opacity: [0.6, 1], duration: 500 });
}
function run(el) {
var s = el._fsp;
s.started = true;
if (!s.steps) { go(el, +el.dataset.total || 0); return; }
var loop = el.dataset.loop !== undefined && el.dataset.loop !== 'false';
var i = 0;
var tick = function () {
if (!s.visible) { s.wait = setTimeout(tick, 400); return; }
if (i < s.steps.length) {
go(el, s.steps[i++]);
s.wait = setTimeout(tick, i === s.steps.length ? DEFAULTS.step + 1600 : DEFAULTS.step);
} else if (loop) {
i = 0;
go(el, 0);
s.wait = setTimeout(tick, 1500);
}
};
tick();
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._fsp;
s.visible = e.isIntersecting;
if (e.isIntersecting && !s.started) run(el);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-free-shipping-progress]').forEach(function (el) {
if (el._fsp) return;
setup(el);
if (reduced()) {
var s = el._fsp;
s.started = true;
var steps = s.steps;
go(el, steps ? steps[steps.length - 1] : +el.dataset.total || 0);
} else io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.freeShipping = function (el, total) {
if (!el._fsp) return;
el._fsp.started = true;
go(el, +total || 0);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-free-shipping-progress data-total="32" data-threshold="50"></div>
<script>
// after adding to the basket:
document.querySelector('[data-free-shipping-progress]').dataset.total = 47;
</script>