Winding Roadmap
A road winds across the page with your milestones along it. A glowing line travels down the road and each milestone lights up as it passes: the pin fills and pulses and its label brightens. Stop it part-way to show where you are now. On a narrow screen the road winds downwards instead.
Kick-off
Prototype
Beta
Launch
Version 2
How to use it
- Paste the snippet just before
</head>. - Add
data-winding-roadmapto a container with one child per milestone, in order. - Add
data-dateto a milestone for a small line above its name.
Options
Add any of these attributes to the same element.
data-date | On a milestone: a short date or phase shown above its name. |
data-current | How many milestones to light, to show where you are now. Default: all of them. |
data-color | Colour of the lit road and pins. Default #a855f7. |
data-road | Colour of the road. Default #e4daf3. |
data-step | Time to travel between milestones. Default 900 ms. |
data-vertical | Always wind downwards, even when there is room across. |
data-loop | Fade the line and travel the road again. |
It fills the width of its container. Text size follows its font-size; keep names to a word or two.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Winding Roadmap: a road winds across the page (or down it, on a narrow
// screen) and a glowing line travels along it, lighting each milestone as
// it passes: the pin fills and pulses, and its label brightens.
// Usage:
// <div data-winding-roadmap>
// <span data-date="Jan">Kick-off</span>
// <span data-date="Mar">Beta</span>
// <span data-date="Jun">Launch</span>
// </div>
// One child per milestone, in order.
// Optional: data-date on a milestone (small line above its name),
// data-current (number of milestones to light, e.g. 2 stops after the
// second; default all), data-color (default #a855f7), data-road (road
// colour, default #e4daf3), data-step (ms per milestone, default 900),
// data-vertical (always wind downwards), data-loop (fade and run again).
var DEFAULTS = { color: '#a855f7', road: '#e4daf3', step: 900 };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.wrm{position:relative;width:100%;--wrm-c:#a855f7}' +
'.wrm-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
'.wrm-pin{transform-box:fill-box;transform-origin:center}' +
'.wrm-label{position:absolute;box-sizing:border-box;line-height:1.2;opacity:.45;transition:opacity .4s}' +
'.wrm-label.wrm-on{opacity:1}' +
'.wrm-date{display:block;font-size:.78em;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--wrm-c)}' +
'.wrm-name{display:block;font-weight:700}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function mk(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function setup(el) {
var d = el.dataset, color = d.color || DEFAULTS.color, road = d.road || DEFAULTS.road;
var s = (el._wrm = { stones: [], visible: false, running: false, t: 0 });
el.classList.add('wrm');
el.style.setProperty('--wrm-c', color);
var svg = mk('svg', { class: 'wrm-svg', 'aria-hidden': 'true' });
s.road = mk('path', { fill: 'none', stroke: road, 'stroke-linecap': 'round' }, svg);
s.lane = mk('path', { fill: 'none', stroke: '#fff', 'stroke-width': 2, 'stroke-dasharray': '7 8', 'stroke-linecap': 'round', opacity: 0.9 }, svg);
s.glow = mk('path', { fill: 'none', stroke: color, 'stroke-linecap': 'round', opacity: 0.25 }, svg);
s.prog = mk('path', { fill: 'none', stroke: color, 'stroke-width': 4, 'stroke-linecap': 'round' }, svg);
s.pinLayer = mk('g', {}, svg);
s.head = mk('circle', { r: 6, fill: '#fff', stroke: color, 'stroke-width': 3, opacity: 0 }, svg);
s.measure = mk('path', { fill: 'none', stroke: 'none' }, svg);
Array.from(el.children).forEach(function (k) {
var label = document.createElement('div');
label.className = 'wrm-label';
if (k.getAttribute('data-date')) {
var dt = document.createElement('span');
dt.className = 'wrm-date';
dt.textContent = k.getAttribute('data-date');
label.appendChild(dt);
}
var nm = document.createElement('span');
nm.className = 'wrm-name';
nm.textContent = k.textContent.trim();
label.appendChild(nm);
el.replaceChild(label, k);
var g = mk('g', {}, s.pinLayer);
var ring = mk('circle', { r: 9, fill: 'none', stroke: color, 'stroke-width': 2, opacity: 0 }, g);
var pin = mk('circle', { class: 'wrm-pin', r: 9, fill: '#fff', stroke: '#c4b3e3', 'stroke-width': 3 }, g);
s.stones.push({ label: label, g: g, pin: pin, ring: ring, text: label.textContent });
});
el.insertBefore(svg, el.firstChild);
el.setAttribute('role', 'img');
if (!el.getAttribute('aria-label')) {
el.setAttribute('aria-label', 'Roadmap: ' + s.stones.map(function (x) {
var dt = x.label.querySelector('.wrm-date'), nm = x.label.querySelector('.wrm-name');
return (dt ? dt.textContent + ', ' : '') + nm.textContent;
}).join('; '));
}
layout(el);
new ResizeObserver(function () { layout(el); }).observe(el);
}
// A smooth S-curve from point to point.
function seg(a, b, vertical) {
if (vertical) {
var my = (a[1] + b[1]) / 2;
return ' C' + a[0] + ',' + my + ' ' + b[0] + ',' + my + ' ' + b[0] + ',' + b[1];
}
var mx = (a[0] + b[0]) / 2;
return ' C' + mx + ',' + a[1] + ' ' + mx + ',' + b[1] + ' ' + b[0] + ',' + b[1];
}
function layout(el) {
var s = el._wrm, n = s.stones.length;
var W = el.clientWidth;
if (!W || !n) return;
var fs = parseFloat(getComputedStyle(el).fontSize) || 14;
var vertical = el.dataset.vertical != null || W / n < fs * 7.5;
var roadW = Math.max(12, Math.min(20, fs * 1.2));
var pts = [], start, end, H, labelW;
s.stones.forEach(function (x) { x.label.style.width = ''; x.label.style.maxWidth = ''; });
if (!vertical) {
var A = Math.max(26, Math.min(58, W * 0.05));
var lh = 0;
labelW = (W / n) * 1.6;
s.stones.forEach(function (x) { x.label.style.maxWidth = labelW + 'px'; lh = Math.max(lh, x.label.offsetHeight); });
var top = lh + 14, mid = top + A + roadW / 2;
H = mid + A + roadW / 2 + 14 + lh;
for (var i = 0; i < n; i++) pts.push([W * (i + 0.5) / n, mid + (i % 2 ? A : -A)]);
start = [roadW / 2, mid + A * 0.4];
end = [W - roadW / 2, n % 2 ? mid + A * 0.4 : mid - A * 0.4];
s.stones.forEach(function (x, i) {
var up = i % 2 === 0;
x.label.style.textAlign = 'center';
x.label.style.left = pts[i][0] + 'px';
x.label.style.translate = '-50% 0';
x.label.style.top = (up ? pts[i][1] - roadW / 2 - 12 - x.label.offsetHeight : pts[i][1] + roadW / 2 + 12) + 'px';
});
} else {
var Av = Math.max(26, Math.min(70, W * 0.13));
var gap = Math.max(fs * 4.4, 54);
labelW = W / 2 - Av - 22;
for (var j = 0; j < n; j++) pts.push([W / 2 + (j % 2 ? Av : -Av), roadW + gap * (j + 0.5)]);
start = [W / 2 - Av * 0.3, roadW / 2];
H = roadW + gap * n;
end = [W / 2 + (n % 2 ? -Av * 0.3 : Av * 0.3), H - roadW / 2];
s.stones.forEach(function (x, i) {
var left = i % 2 === 0;
x.label.style.width = labelW + 'px';
x.label.style.textAlign = left ? 'right' : 'left';
x.label.style.left = (left ? pts[i][0] - 20 - labelW : pts[i][0] + 20) + 'px';
x.label.style.translate = '0 -50%';
x.label.style.top = pts[i][1] + 'px';
});
}
el.style.height = H + 'px';
var dPath = 'M' + start[0] + ',' + start[1], cum = [];
var all = [start].concat(pts, [end]);
var acc = 0;
for (var k = 1; k < all.length; k++) {
var piece = 'M' + all[k - 1][0] + ',' + all[k - 1][1] + seg(all[k - 1], all[k], vertical);
s.measure.setAttribute('d', piece);
acc += s.measure.getTotalLength();
if (k <= n) cum.push(acc);
dPath += seg(all[k - 1], all[k], vertical);
}
[s.road, s.lane, s.glow, s.prog].forEach(function (p) { p.setAttribute('d', dPath); });
s.road.setAttribute('stroke-width', roadW);
s.glow.setAttribute('stroke-width', roadW);
s.L = s.prog.getTotalLength();
s.prog.setAttribute('stroke-dasharray', s.L + ' ' + (s.L + 10));
s.glow.setAttribute('stroke-dasharray', s.L + ' ' + (s.L + 10));
s.stones.forEach(function (x, i) {
x.at = cum[i];
x.g.setAttribute('transform', 'translate(' + pts[i][0] + ',' + pts[i][1] + ')');
});
s.stopAt = stopLength(el);
draw(el, s.t);
}
function stopLength(el) {
var s = el._wrm, c = parseInt(el.dataset.current, 10);
if (!(c >= 0) || c >= s.stones.length) return s.L;
return c === 0 ? 0 : s.stones[c - 1].at + 1;
}
function draw(el, t) {
var s = el._wrm;
s.t = Math.min(t, s.L);
var off = s.L - s.t;
s.prog.setAttribute('stroke-dashoffset', off);
s.glow.setAttribute('stroke-dashoffset', off);
var p = s.prog.getPointAtLength(Math.max(0.01, s.t));
s.head.setAttribute('cx', p.x);
s.head.setAttribute('cy', p.y);
s.stones.forEach(function (x) {
var on = s.t >= x.at - 0.5;
if (on === !!x.on) return;
x.on = on;
setLit(el, x, on, s.animate);
});
}
function setLit(el, x, on, animate) {
var color = el.dataset.color || DEFAULTS.color;
x.label.classList.toggle('wrm-on', on);
x.pin.setAttribute('fill', on ? color : '#fff');
x.pin.setAttribute('stroke', on ? '#fff' : '#c4b3e3');
if (on && animate) {
anime.animate(x.pin, { scale: [1, 1.5, 1], duration: 500, ease: 'out(3)' });
anime.animate(x.ring, { r: [9, 24], opacity: [0.7, 0], duration: 800, ease: 'out(2)' });
anime.animate(x.label, { scale: [1, 1.08, 1], duration: 500, ease: 'out(2)' });
}
}
function run(el) {
var s = el._wrm, d = el.dataset, step = +d.step || DEFAULTS.step;
s.running = true;
s.animate = true;
var target = s.stopAt, o = { t: 0 };
var count = s.stones.filter(function (x) { return x.at <= target; }).length || 1;
anime.animate(s.head, { opacity: [0, 1], duration: 300 });
anime.animate(o, {
t: [0, 1], duration: step * (count + 0.5), ease: 'inOut(1.4)',
onUpdate: function () { draw(el, o.t * s.stopAt); },
onComplete: function () {
if (s.stopAt >= s.L) anime.animate(s.head, { opacity: 0, duration: 400, delay: 200 });
else anime.animate(s.head, { r: [6, 8, 6], duration: 900, loop: 2 });
setTimeout(function () {
if (d.loop == null) { s.running = false; return; }
anime.animate([s.prog, s.glow, s.head], { opacity: 0, duration: 500 });
s.stones.forEach(function (x) {
anime.animate(x.pin, { scale: [1, 0.7, 1], duration: 400, delay: 200 });
});
setTimeout(function () {
s.animate = false;
draw(el, 0);
anime.utils.set(s.prog, { opacity: 1 });
anime.utils.set(s.glow, { opacity: 0.25 });
s.running = false;
if (s.visible) run(el);
}, 700);
}, 1800);
},
});
}
function init() {
var still = reduced();
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._wrm;
s.visible = e.isIntersecting;
if (!s.visible || s.running) return;
if (!s.introduced) {
s.introduced = true;
anime.animate(s.stones.map(function (x) { return x.g; }), { opacity: [0, 1], duration: 400, delay: anime.stagger(80) });
anime.animate(s.stones.map(function (x) { return x.label; }), { opacity: [0, 0.45], duration: 500, delay: anime.stagger(80) });
setTimeout(function () { s.stones.forEach(function (x) { x.label.style.opacity = ''; }); run(el); }, 500);
return;
}
run(el);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-winding-roadmap]').forEach(function (el) {
if (el._wrm) return;
setup(el);
var s = el._wrm;
if (still) { draw(el, s.stopAt); return; }
draw(el, 0);
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-winding-roadmap data-current="3">
<span data-date="Jan">Kick-off</span>
<span data-date="Mar">Prototype</span>
<span data-date="May">Beta</span>
<span data-date="Aug">Launch</span>
</div>