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.
- Ordered
- Packed
- Shipped
- Out for delivery
- Delivered
How to use it
- Paste the snippet just before
</head>. - Add
data-order-trackingto a list of the delivery stages, one<li>each. - Set
data-currentto 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-current | The stage reached, from 0. Default 0. |
data-color | Colour of the route and ticks. Default #6a0dad. |
data-duration | How long the parcel takes between stages. Default 900 ms. |
data-loop | Travel 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.
<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><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>