← E-commerce

Order Tracking

A parcel travels along a winding route through each delivery stage. The route fills in behind it, each stop pops into a tick as the parcel arrives, and the stage it has reached is highlighted below.

  1. Ordered
  2. Packed
  3. Shipped
  4. Out for delivery
  5. Delivered

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-order-tracking to a list of the delivery stages, one <li> each.
  3. Set data-current to the stage the order has reached, counting from 0. Change it later and the parcel travels on.

Options

Add any of these attributes to the same element.

data-currentThe stage reached, from 0. Default 0.
data-colorColour of the route and ticks. Default #6a0dad.
data-durationHow long the parcel takes between stages. Default 900 ms.
data-loopTravel the whole route, wait, and start again. Handy for demos.

The stage reached gets aria-current="step", so screen readers know where the order is.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Order Tracking: a parcel travels along a winding route through each
    // delivery stage. The route fills in behind it, each stop pops into a tick
    // as the parcel reaches it, and the stage it is at is highlighted.
    // Usage:
    //   <ol data-order-tracking data-current="2">
    //     <li>Ordered</li><li>Packed</li><li>Shipped</li>
    //     <li>Out for delivery</li><li>Delivered</li>
    //   </ol>
    // Each li is a stage; data-current is the (0-based) stage the order has
    // reached. The parcel travels there from the first stage when the list
    // scrolls into view. Change data-current later and it travels on.
    // Optional: data-current (default 0), data-color (route and ticks,
    // default #6a0dad), data-duration (ms per stage, default 900), data-loop
    // (travel the whole route, wait, and start again; handy for demos).
    var DEFAULTS = { color: '#6a0dad', duration: 900, pause: 450 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.otr{position:relative;width:100%}' +
      '.otr-map{position:relative;height:5em}' +
      '.otr-map svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.otr-base{fill:none;stroke:rgba(15,15,30,.18);stroke-width:3;stroke-linecap:round;stroke-dasharray:1 8}' +
      '.otr-done{fill:none;stroke:var(--otr-color);stroke-width:4;stroke-linecap:round}' +
      '.otr-stop circle{fill:#fff;stroke:rgba(15,15,30,.2);stroke-width:2.5;transition:fill .3s,stroke .3s}' +
      '.otr-stop path{fill:none;stroke:#fff;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round;opacity:0}' +
      '.otr-stop.otr-on circle{fill:var(--otr-color);stroke:var(--otr-color)}' +
      '.otr-stop.otr-on path{opacity:1}' +
      '.otr-stop{transform-box:fill-box;transform-origin:center}' +
      '.otr-parcel{position:absolute;left:0;top:0;width:1.7em;height:1.7em;margin:-1.95em 0 0 -.85em;transform-origin:50% 100%;pointer-events:none}' +
      '.otr-parcel svg{display:block;width:100%;height:100%;filter:drop-shadow(0 3px 4px rgba(15,15,30,.2))}' +
      '.otr-list{display:flex;margin:.35em 0 0;padding:0;list-style:none}' +
      '.otr-list>li{flex:1 1 0;min-width:0;padding:0 .2em;text-align:center;font-size:.8em;line-height:1.25;color:rgba(15,15,30,.45);' +
      'overflow-wrap:break-word;transition:color .3s}' +
      '.otr-list>li.otr-reached{color:inherit}' +
      '.otr-list>li[aria-current]{color:var(--otr-color);font-weight:700}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var PARCEL = '<svg viewBox="0 0 32 32" aria-hidden="true">' +
      '<path d="M16 3l12 6v14l-12 6-12-6V9z" fill="#e7a95c"/>' +
      '<path d="M4 9l12 6 12-6" fill="none" stroke="#c98a3e" stroke-width="1.4" stroke-linejoin="round"/>' +
      '<path d="M16 15v14" stroke="#c98a3e" stroke-width="1.4"/>' +
      '<path d="M16 15l12-6v14l-12 6z" fill="#000" opacity=".1"/>' +
      '<path d="M10 6l12 6v4" fill="none" stroke="#fff" stroke-width="2.2" opacity=".7"/>' +
      '</svg>';

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function el$(tag, cls) { var n = document.createElementNS(NS, tag); if (cls) n.setAttribute('class', cls); return n; }

    function setup(list) {
      var items = Array.prototype.filter.call(list.children, function (c) { return c.tagName === 'LI'; });
      if (items.length < 2) return;
      var d = list.dataset;
      var wrap = document.createElement('div');
      wrap.className = 'otr';
      wrap.style.setProperty('--otr-color', d.color || DEFAULTS.color);
      list.parentNode.insertBefore(wrap, list);
      var map = document.createElement('div');
      map.className = 'otr-map';
      map.setAttribute('aria-hidden', 'true');
      var svg = el$('svg');
      var base = el$('path', 'otr-base');
      var done = el$('path', 'otr-done');
      svg.appendChild(base);
      svg.appendChild(done);
      var stops = items.map(function () {
        var g = el$('g', 'otr-stop');
        var c = el$('circle');
        c.setAttribute('r', 8);
        var t = el$('path');
        g.appendChild(c);
        g.appendChild(t);
        svg.appendChild(g);
        return g;
      });
      var parcel = document.createElement('div');
      parcel.className = 'otr-parcel';
      parcel.innerHTML = PARCEL;
      map.appendChild(svg);
      map.appendChild(parcel);
      wrap.appendChild(map);
      wrap.appendChild(list);
      list.classList.add('otr-list');

      var s = list._otr = {
        wrap: wrap, map: map, base: base, done: done, stops: stops, items: items, parcel: parcel,
        pos: 0, reached: 0, lens: [], total: 0, anim: null
      };
      layout(list);
      new ResizeObserver(function () { layout(list); }).observe(map);
      new MutationObserver(function () { if (s.started && !s.looping) travel(list, target(list)); })
        .observe(list, { attributes: true, attributeFilter: ['data-current'] });
    }

    // Build the winding route through one point per stage, in pixels.
    function layout(list) {
      var s = list._otr;
      var w = s.map.clientWidth, h = s.map.clientHeight, n = s.items.length;
      if (!w) return;
      var pts = s.items.map(function (_, i) { return [(i + 0.5) / n * w, h * (i % 2 ? 0.5 : 0.78)]; });
      var dd = 'M' + pts[0][0] + ' ' + pts[0][1];
      var probe = el$('path');
      s.map.firstChild.appendChild(probe);
      var lens = [0];
      for (var i = 1; i < n; i++) {
        var a = pts[i - 1], b = pts[i], mx = (a[0] + b[0]) / 2;
        var seg = ' C' + mx + ' ' + a[1] + ' ' + mx + ' ' + b[1] + ' ' + b[0] + ' ' + b[1];
        probe.setAttribute('d', 'M' + a[0] + ' ' + a[1] + seg);
        lens.push(lens[i - 1] + probe.getTotalLength());
        dd += seg;
      }
      probe.remove();
      s.base.setAttribute('d', dd);
      s.done.setAttribute('d', dd);
      s.lens = lens;
      s.total = lens[n - 1];
      s.done.style.strokeDasharray = s.total + ' ' + s.total;
      s.stops.forEach(function (g, i) {
        var x = pts[i][0], y = pts[i][1];
        g.firstChild.setAttribute('cx', x);
        g.firstChild.setAttribute('cy', y);
        g.lastChild.setAttribute('d', 'M' + (x - 3.6) + ' ' + y + 'l2.5 2.6l4.8-5.2');
      });
      place(list);
    }

    // Put the parcel and the filled route at s.pos (a stage number, may be fractional).
    function place(list) {
      var s = list._otr;
      if (!s.lens.length) return;
      var i = Math.floor(s.pos), f = s.pos - i;
      var len = i >= s.lens.length - 1 ? s.total : s.lens[i] + (s.lens[i + 1] - s.lens[i]) * f;
      var p = s.base.getPointAtLength(len);
      var q = s.base.getPointAtLength(Math.min(s.total, len + 2));
      var r = s.base.getPointAtLength(Math.max(0, len - 2));
      var tilt = Math.atan2(q.y - r.y, q.x - r.x) * 180 / Math.PI;
      s.parcel.style.left = p.x + 'px';
      s.parcel.style.top = p.y + 'px';
      s.parcel.style.rotate = (f > 0 && f < 1 ? tilt * 0.5 : 0) + 'deg';
      s.done.style.strokeDashoffset = s.total - len;
    }

    function mark(list, k, animate) {
      var s = list._otr;
      s.items.forEach(function (li, i) {
        li.classList.toggle('otr-reached', i <= k);
        if (i === k) li.setAttribute('aria-current', 'step'); else li.removeAttribute('aria-current');
        s.stops[i].classList.toggle('otr-on', i <= k);
      });
      if (animate) {
        anime.animate(s.stops[k], { scale: [0.4, 1.25, 1], duration: 500, ease: 'out(3)' });
        anime.animate(s.items[k], { y: [-4, 0], duration: 450, ease: 'outBack(2)' });
      }
    }

    function target(list) {
      return Math.max(0, Math.min(list._otr.items.length - 1, parseInt(list.dataset.current, 10) || 0));
    }

    // Travel stage by stage from where the parcel is to stage k.
    function travel(list, k, then) {
      var s = list._otr;
      if (s.anim) s.anim.pause();
      clearTimeout(s.wait);
      if (reduced()) { s.pos = k; place(list); mark(list, k); if (then) then(); return; }
      var dur = +list.dataset.duration || DEFAULTS.duration;
      var step = function () {
        var here = Math.round(s.pos);
        if (here === k && Math.abs(s.pos - k) < 0.001) { if (then) then(); return; }
        var next = k > here ? here + 1 : here - 1;
        if (k < here) { s.pos = k; place(list); mark(list, k); if (then) then(); return; }
        s.anim = anime.animate(s, {
          pos: next,
          duration: dur,
          ease: 'inOut(2)',
          onUpdate: function () { place(list); },
          onComplete: function () {
            place(list);
            mark(list, next, true);
            anime.animate(s.parcel, { scaleY: [0.8, 1], scaleX: [1.15, 1], duration: 420, ease: 'outElastic(1, .5)' });
            s.wait = setTimeout(step, DEFAULTS.pause);
          }
        });
      };
      step();
    }

    function loop(list) {
      var s = list._otr;
      s.looping = true;
      var last = s.items.length - 1;
      travel(list, last, function () {
        anime.animate(s.parcel, { y: [0, -10, 0], duration: 500, ease: 'out(2)' });
        s.wait = setTimeout(function () {
          anime.animate([s.parcel, s.done], { opacity: 0, duration: 350, ease: 'in(2)' });
          s.wait = setTimeout(function () {
            s.pos = 0;
            place(list);
            mark(list, 0);
            anime.animate([s.parcel, s.done], { opacity: 1, duration: 350 });
            if (s.visible) s.wait = setTimeout(function () { loop(list); }, 500);
            else s.pending = true;
          }, 400);
        }, 2200);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var list = e.target._otrList, s = list._otr;
          s.visible = e.isIntersecting;
          if (!e.isIntersecting) return;
          var looped = list.dataset.loop !== undefined && list.dataset.loop !== 'false';
          if (!s.started) {
            s.started = true;
            if (looped) loop(list); else travel(list, target(list));
          } else if (s.pending) { s.pending = false; loop(list); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-order-tracking]').forEach(function (list) {
        if (list._otr) return;
        setup(list);
        if (!list._otr) return;
        mark(list, 0);
        // The observer watches the wrapper, which points back to the list.
        list._otr.wrap._otrList = list;
        if (reduced()) { list._otr.started = true; travel(list, target(list)); }
        else io.observe(list._otr.wrap);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<ol data-order-tracking data-current="2">
  <li>Ordered</li>
  <li>Packed</li>
  <li>Shipped</li>
  <li>Out for delivery</li>
  <li>Delivered</li>
</ol>