← E-commerce

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

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-free-shipping-progress, the basket total in data-total and the threshold in data-threshold.
  3. When the basket changes, update data-total (or call animacious.freeShipping(element, 41)) and the van drives to the new spot.

Options

Add any of these attributes to the same element.

data-totalThe basket total so far.
data-thresholdWhat the basket must reach for free delivery. Default 50.
data-currencyCurrency symbol. Default £.
data-colorBar and van colour. Default #6a0dad.
data-done-colorBar colour once free delivery is unlocked. Default #16a34a.
data-stepsA list of totals to play through, e.g. 14,31,44,56. Handy for demos.
data-loopWith data-steps: start again at the end.

It is a progressbar for screen readers, and the message is announced as it changes.

1 · Snippet
<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&rsquo;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>
2 · Your HTML
<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>