← Diagrams & maps

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

  1. Paste the snippet just before </head>.
  2. Add data-winding-roadmap to a container with one child per milestone, in order.
  3. Add data-date to a milestone for a small line above its name.

Options

Add any of these attributes to the same element.

data-dateOn a milestone: a short date or phase shown above its name.
data-currentHow many milestones to light, to show where you are now. Default: all of them.
data-colorColour of the lit road and pins. Default #a855f7.
data-roadColour of the road. Default #e4daf3.
data-stepTime to travel between milestones. Default 900 ms.
data-verticalAlways wind downwards, even when there is room across.
data-loopFade 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.

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