← Diagrams & maps

Metro Line Map

A metro map draws itself. Each coloured line snakes out from its first stop, rounding its corners like a printed map, and stations pop up with their names as the line reaches them. Where lines cross, the stop becomes a bold interchange ring.

Westgate Mill Lane Park Central Museum Riverside Eastfield Southport Elm Park Ash Street Kings Cross Abbey Airport North Hill Market Docks Harbour

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-metro-line-map to a container and set the size of your map’s grid with data-view, such as 0 0 400 260.
  3. Add a child for each line with data-route (its corners as x,y pairs) and data-color, then a child for each station with data-station="x,y" and its name as text.
  4. Optionally, put an inline SVG with the same viewBox first, for a river or parks behind the lines.

Options

Add any of these attributes to the same element.

data-viewThe map’s grid, as an SVG viewBox. Default 0 0 400 260.
data-routeOn a line: its corners, in order, as x,y pairs separated by spaces.
data-colorOn a line: its colour.
data-stationOn a station: where it is, as x,y. It must sit on a line; on two or more it becomes an interchange.
data-sideOn a station: where its name goes, n, s, e, w, ne, nw, se or sw. Default s.
data-widthLine thickness, in map units. Default 7.
data-speedHow fast each line draws, in map units a second. Default 260.
data-gapTime between one line starting and the next. Default 650 ms.
data-labelsSet to off to hide station names.
data-loopFade out and draw the map again.

Keep lines to horizontal, vertical and 45° steps for the classic look. Names are text over the map, sized by the container’s font-size, so they stay readable on a phone.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Metro Line Map: coloured lines draw themselves across a metro map one
    // after another, and each station pops up with its name as the line
    // reaches it. Stations where lines meet become interchanges.
    // Usage:
    //   <div data-metro-line-map data-view="0 0 400 260">
    //     <span data-color="#e11d48" data-route="20,140 100,140 150,90 260,90"></span>
    //     <span data-station="20,140" data-side="s">Westgate</span>
    //     …
    //   </div>
    // Coordinates are in the units of data-view (an SVG viewBox). A line is a
    // child with data-route (its corners as "x,y" pairs) and data-color. A
    // station is a child with data-station ("x,y") and its name as text;
    // data-side (n, s, e, w, ne, nw, se or sw) puts the name on that side.
    // Put an inline SVG with the same viewBox first for a backdrop (a river,
    // parks).
    // Optional: data-view (default "0 0 400 260"), data-speed (map units per
    // second each line draws at, default 260), data-gap (ms between lines
    // starting, default 650), data-width (line thickness in map units,
    // default 7), data-labels="off" (no station names), data-loop (fade out
    // and draw the map again).
    var DEFAULTS = { view: '0 0 400 260', speed: 260, gap: 650, width: 7 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.mtl{position:relative;display:block;line-height:0}' +
      '.mtl>svg{display:block;width:100%;height:auto}' +
      '.mtl-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.mtl-svg:first-child{position:static}' +
      '.mtl-stn{transform-box:fill-box;transform-origin:center}' +
      '.mtl-lab{position:absolute;line-height:1.15;font-weight:700;font-size:.78em;color:#241a35;white-space:nowrap;pointer-events:none;' +
        'text-shadow:0 0 .2em #fff,0 0 .2em #fff,0 0 .35em #fff;opacity:0}' +
      '.mtl-x{font-weight:800}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // Where a label sits relative to its station, as [translate x %, translate y %, offset x em, offset y em].
    var SIDES = {
      n: [-50, -100, 0, -0.75], s: [-50, 0, 0, 0.75], e: [0, -50, 0.85, 0], w: [-100, -50, -0.85, 0],
      ne: [0, -100, 0.55, -0.55], nw: [-100, -100, -0.55, -0.55], se: [0, 0, 0.55, 0.55], sw: [-100, 0, -0.55, 0.55],
    };

    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 pts(text) {
      return (text || '').trim().split(/\s+/).map(function (p) { return p.split(',').map(Number); })
        .filter(function (p) { return p.length === 2 && !isNaN(p[0]) && !isNaN(p[1]); });
    }

    // A route with rounded corners, like a printed metro map.
    function routePath(p, r) {
      var d = 'M' + p[0][0] + ' ' + p[0][1];
      for (var i = 1; i < p.length - 1; i++) {
        var a = p[i - 1], b = p[i], c = p[i + 1];
        var l1 = Math.hypot(b[0] - a[0], b[1] - a[1]), l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
        var k = Math.min(r, l1 / 2, l2 / 2);
        d += 'L' + (b[0] - (b[0] - a[0]) / l1 * k).toFixed(2) + ' ' + (b[1] - (b[1] - a[1]) / l1 * k).toFixed(2) +
          'Q' + b[0] + ' ' + b[1] + ' ' + (b[0] + (c[0] - b[0]) / l2 * k).toFixed(2) + ' ' + (b[1] + (c[1] - b[1]) / l2 * k).toFixed(2);
      }
      var z = p[p.length - 1];
      return d + 'L' + z[0] + ' ' + z[1];
    }

    // How far along a route a point is (as a share of its length), or -1 if it isn't on it.
    function along(p, q) {
      var total = 0, at = -1, i, segs = [];
      for (i = 1; i < p.length; i++) { segs.push(total); total += Math.hypot(p[i][0] - p[i - 1][0], p[i][1] - p[i - 1][1]); }
      for (i = 1; i < p.length && at < 0; i++) {
        var ax = p[i - 1][0], ay = p[i - 1][1], dx = p[i][0] - ax, dy = p[i][1] - ay, len2 = dx * dx + dy * dy;
        var t = len2 ? Math.max(0, Math.min(1, ((q[0] - ax) * dx + (q[1] - ay) * dy) / len2)) : 0;
        if (Math.hypot(ax + dx * t - q[0], ay + dy * t - q[1]) < 2.5) at = segs[i - 1] + t * Math.sqrt(len2);
      }
      return at < 0 ? -1 : at / total;
    }

    function setup(el) {
      var d = el.dataset;
      var s = (el._mtl = { lines: [], stations: [] });
      el.classList.add('mtl');
      var view = (d.view || DEFAULTS.view).split(/[\s,]+/).map(Number);
      var width = +d.width || DEFAULTS.width;
      var labels = d.labels !== 'off';
      var kids = Array.from(el.children);
      var backdrop = kids[0] && kids[0].tagName.toLowerCase() === 'svg' ? kids.shift() : null;
      s.svg = mk('svg', { class: 'mtl-svg', viewBox: view.join(' '), 'aria-hidden': 'true' });
      if (backdrop) el.insertBefore(s.svg, backdrop.nextSibling);
      else el.insertBefore(s.svg, el.firstChild);
      var gLines = mk('g', { fill: 'none', 'stroke-width': width, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, s.svg);
      var gStations = mk('g', {}, s.svg);
      var names = [];
      kids.forEach(function (k) {
        if (k.hasAttribute('data-route')) {
          var p = pts(k.getAttribute('data-route'));
          if (p.length < 2) return;
          var color = k.getAttribute('data-color') || '#7c3aed';
          var path = mk('path', { d: routePath(p, width * 1.8), stroke: color, pathLength: 1, 'stroke-dasharray': '1 2', 'stroke-dashoffset': 1.01 }, gLines);
          s.lines.push({ pts: p, path: path, color: color, len: path.getTotalLength() });
          k.remove();
        } else if (k.hasAttribute('data-station')) {
          var q = pts(k.getAttribute('data-station'))[0];
          if (q) s.stations.push({ at: q, name: k.textContent.trim(), side: (k.getAttribute('data-side') || 's').toLowerCase() });
          k.remove();
        }
      });
      s.stations.forEach(function (st) {
        st.on = [];
        s.lines.forEach(function (ln, i) {
          var f = along(ln.pts, st.at);
          if (f >= 0) st.on.push({ line: i, f: f });
        });
        var x = st.on.length > 1;
        var color = st.on.length ? s.lines[st.on[0].line].color : '#241a35';
        st.dot = mk('circle', {
          class: 'mtl-stn', cx: st.at[0], cy: st.at[1], r: x ? width * 0.95 : width * 0.55,
          fill: '#fff', stroke: x ? '#241a35' : color, 'stroke-width': x ? width * 0.36 : width * 0.32, opacity: 0,
        }, gStations);
        if (labels && st.name) {
          var lab = document.createElement('span');
          var sd = SIDES[st.side] || SIDES.s;
          lab.className = 'mtl-lab' + (x ? ' mtl-x' : '');
          lab.textContent = st.name;
          lab.setAttribute('aria-hidden', 'true');
          lab.style.left = 'calc(' + ((st.at[0] - view[0]) / view[2]) * 100 + '% + ' + sd[2] + 'em)';
          lab.style.top = 'calc(' + ((st.at[1] - view[1]) / view[3]) * 100 + '% + ' + sd[3] + 'em)';
          lab.style.translate = sd[0] + '% ' + sd[1] + '%';
          el.appendChild(lab);
          st.lab = lab;
        }
        if (st.name) names.push(st.name);
      });
      if (!el.hasAttribute('aria-label')) {
        el.setAttribute('role', 'img');
        el.setAttribute('aria-label', 'Metro map with ' + s.lines.length + ' lines' + (names.length ? ': ' + names.join(', ') : ''));
      }
      s.speed = +d.speed || DEFAULTS.speed;
      s.gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
    }

    function play(el) {
      var s = el._mtl, end = 0;
      // When each line starts and how long it takes, so stations know when they're reached.
      s.lines.forEach(function (ln, i) {
        ln.t0 = 200 + i * s.gap;
        ln.dur = Math.max(500, ln.len / s.speed * 1000);
        anime.animate(ln.path, { strokeDashoffset: [1.01, 0], duration: ln.dur, delay: ln.t0, ease: 'inOut(1.4)' });
        end = Math.max(end, ln.t0 + ln.dur);
      });
      s.stations.forEach(function (st) {
        var t = Infinity;
        st.on.forEach(function (o) {
          var ln = s.lines[o.line];
          // Undo the ease to find when the drawing head passes this share of the line.
          var f = o.f, lo = 0, hi = 1;
          for (var k = 0; k < 20; k++) { var mid = (lo + hi) / 2; if (easeIO(mid) < f) lo = mid; else hi = mid; }
          t = Math.min(t, ln.t0 + lo * ln.dur);
        });
        if (!isFinite(t)) t = end;
        st.dot.setAttribute('opacity', 1);
        anime.animate(st.dot, { scale: [0, 1.5, 1], duration: 480, delay: t, ease: 'out(2)' });
        if (st.lab) anime.animate(st.lab, { opacity: [0, 1], scale: [0.7, 1], duration: 380, delay: t + 80, ease: 'outBack(2)' });
      });
      if (el.dataset.loop === undefined) return;
      setTimeout(function () {
        var parts = [s.svg].concat(s.stations.map(function (st) { return st.lab; }).filter(Boolean));
        anime.animate(parts, { opacity: 0, duration: 500, ease: 'in(2)', onComplete: function () {
          s.lines.forEach(function (ln) { ln.path.setAttribute('stroke-dashoffset', 1.01); });
          s.stations.forEach(function (st) { anime.utils.set(st.dot, { scale: 0 }); });
          s.svg.style.opacity = 1;
          setTimeout(function () { play(el); }, 300);
        } });
      }, end + 3000);
    }

    // anime's 'inOut(1.4)', used to time the stations.
    function easeIO(t) { return t < 0.5 ? Math.pow(2 * t, 1.4) / 2 : 1 - Math.pow(2 - 2 * t, 1.4) / 2; }

    function still(el) {
      var s = el._mtl;
      s.lines.forEach(function (ln) { ln.path.setAttribute('stroke-dashoffset', 0); });
      s.stations.forEach(function (st) { st.dot.setAttribute('opacity', 1); if (st.lab) st.lab.style.opacity = 1; });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-metro-line-map]').forEach(function (el) {
        if (el._mtl) return;
        setup(el);
        if (reduced()) still(el);
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-metro-line-map data-view="0 0 400 260">
  <span data-color="#e11d48" data-route="20,140 100,140 150,90 260,90"></span>
  <span data-color="#2563eb" data-route="200,14 200,180 240,220 370,220"></span>
  <span data-station="20,140">Westgate</span>
  <span data-station="200,90" data-side="ne">Central</span>
  <span data-station="370,220" data-side="n">Harbour</span>
</div>