← SVG & icons

Map Route Draw

A route draws itself across a map from pin to pin. Each pin drops in with a springy bounce just as the line reaches it, and a little arrow rides the tip, turning at every corner. Great for delivery tracking, directions and travel stories.

Home Café Park

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-map-route-draw to your map’s <svg>.
  3. Mark the line with data-route. Several routes draw together, so a wide white casing under the line works too.
  4. Mark each pin with data-pin, drawn with its tip at 0,0 and placed with transform="translate(x y)". Optionally add a data-traveller, drawn pointing right, to ride the tip.

Options

Add any of these attributes to the same element.

data-durationTime to draw the route. Default 2600 ms.
data-delayWait before it starts.
data-loopDraw, hold, fade and draw again.
data-holdHow long the finished route stays, when looping. Default 2200 ms.

Pins drop in the order the route reaches them, wherever they are in your markup. It starts when scrolled into view. With reduced motion the route and pins simply show.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Map Route Draw: a route draws itself across a map from pin to pin. Each
    // pin drops in as the line reaches it, and a marker can ride the tip of
    // the line. Starts when scrolled into view.
    // Usage:
    //   <svg data-map-route-draw viewBox="0 0 400 260">
    //     …your map…
    //     <path data-route d="M40 200 C…" fill="none" stroke="#a855f7" stroke-width="4"/>
    //     <g data-pin transform="translate(40 200)">…drawn with its tip at 0,0…</g>
    //     <g data-pin transform="translate(350 60)">…</g>
    //     <circle data-traveller r="6" fill="#fff"/>
    //   </svg>
    // Several data-route paths draw together (e.g. a wide white casing under
    // the line); the first one sets where the pins are. The traveller is drawn
    // centred on 0,0, pointing right, and turns with the road.
    // Optional: data-duration (ms to draw, default 2600), data-delay (ms),
    // data-loop (draw, hold, fade and repeat), data-hold (ms it stays drawn
    // when looping, default 2200).
    var DEFAULTS = { duration: 2600, hold: 2200 };
    var NS = 'http://www.w3.org/2000/svg';

    function setup(svg) {
      var routes = Array.from(svg.querySelectorAll('[data-route]'));
      if (!routes.length) return null;
      // Each pin's artwork goes in an inner group, so it can grow from its tip.
      var pins = Array.from(svg.querySelectorAll('[data-pin]')).map(function (pin) {
        var inner = document.createElementNS(NS, 'g');
        while (pin.firstChild) inner.appendChild(pin.firstChild);
        pin.appendChild(inner);
        return { el: pin, inner: inner, at: 0, shown: false };
      });
      var o = { svg: svg, routes: routes, pins: pins, traveller: svg.querySelector('[data-traveller]'), state: { p: 0 } };
      anime.utils.set(pins.map(function (p) { return p.inner; }), { scale: 0 });
      if (o.traveller) o.traveller.style.opacity = 0;
      render(o);
      return o;
    }

    function measure(o) {
      var main = o.routes[0];
      o.lengths = o.routes.map(function (r) { return r.getTotalLength(); });
      // Where along the route each pin sits: the closest of a few hundred samples.
      var toSvg = o.svg.getScreenCTM().inverse();
      var m = toSvg.multiply(main.getScreenCTM());
      var samples = [];
      for (var i = 0; i <= 300; i++) {
        var pt = main.getPointAtLength((o.lengths[0] * i) / 300);
        samples.push([m.a * pt.x + m.c * pt.y + m.e, m.b * pt.x + m.d * pt.y + m.f]);
      }
      o.pins.forEach(function (pin) {
        var t = toSvg.multiply(pin.el.getScreenCTM());
        var best = 0, dist = Infinity;
        samples.forEach(function (s, i) {
          var dd = (s[0] - t.e) * (s[0] - t.e) + (s[1] - t.f) * (s[1] - t.f);
          if (dd < dist) { dist = dd; best = i; }
        });
        pin.at = best / 300;
      });
      o.matrix = m;
    }

    function render(o) {
      var p = o.state.p;
      o.routes.forEach(function (r, i) {
        var len = o.lengths ? o.lengths[i] : 1e5;
        r.style.strokeDasharray = len + ' ' + len;
        r.style.strokeDashoffset = len * (1 - p);
      });
      // Drop each pin as the line gets to it.
      o.pins.forEach(function (pin) {
        if (pin.shown || !o.lengths || p < pin.at - 0.005) return;
        pin.shown = true;
        anime.animate(pin.inner, { scale: [0, 1], duration: 700, ease: 'outElastic(1, .55)' });
      });
      var tr = o.traveller;
      if (tr && o.lengths) {
        var main = o.routes[0], len = o.lengths[0], m = o.matrix;
        var at = len * p;
        var a = main.getPointAtLength(at);
        // Face along the road: from a little behind the tip to a little ahead.
        var back = main.getPointAtLength(Math.max(0, at - 2)), ahead = main.getPointAtLength(Math.min(len, at + 2));
        var x = m.a * a.x + m.c * a.y + m.e, y = m.b * a.x + m.d * a.y + m.f;
        var dx = ahead.x - back.x, dy = ahead.y - back.y;
        var angle = (Math.atan2(m.b * dx + m.d * dy, m.a * dx + m.c * dy) * 180) / Math.PI;
        tr.setAttribute('transform', 'translate(' + x + ' ' + y + ') rotate(' + angle + ')');
        tr.style.opacity = p > 0 && p < 1 ? 1 : 0;
      }
    }

    function run(o) {
      var d = o.svg.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var hold = +d.hold || DEFAULTS.hold;
      var draw = function () { render(o); };
      measure(o);
      var tl;
      (function cycle(first) {
        o.state.p = 0;
        o.pins.forEach(function (pin) { pin.shown = false; anime.utils.set(pin.inner, { scale: 0, opacity: 1 }); });
        o.routes.forEach(function (r) { r.style.opacity = ''; });
        render(o);
        tl = anime.createTimeline({ delay: first ? +d.delay || 0 : 400, onComplete: loop ? function () { cycle(false); } : null });
        tl.add(o.state, { p: [0, 1], duration: duration, ease: 'inOut(1.5)', onUpdate: draw });
        if (loop) {
          tl.add(o.routes, { opacity: 0, duration: 600, ease: 'out(2)' }, '+=' + hold);
          tl.add(o.pins.map(function (pin) { return pin.inner; }), { scale: 0.6, opacity: 0, duration: 500, ease: 'in(2)', delay: anime.stagger(80) }, '<<');
        }
      })(true);
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) tl.play(); else tl.pause();
      }).observe(o.svg);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          run(e.target._mrd);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-map-route-draw]').forEach(function (svg) {
        // With reduced motion the finished route shows; the traveller has nowhere to go.
        if (reduced) svg.querySelectorAll('[data-traveller]').forEach(function (t) { t.style.display = 'none'; });
        if (svg._mrd || reduced) return;
        svg._mrd = setup(svg);
        if (svg._mrd) io.observe(svg);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<svg data-map-route-draw viewBox="0 0 400 250">
  <image href="map.png" width="400" height="250"/>
  <path data-route d="M60 220 V185 H150 V105 H330 V44" fill="none" stroke="#7c3aed" stroke-width="5"/>
  <g data-pin transform="translate(60 220)">
    <path d="M0 0 C-2 -6 -9 -10 -9 -17 A9 9 0 1 1 9 -17 C9 -10 2 -6 0 0 Z" fill="#7c3aed"/>
  </g>
  <g data-pin transform="translate(330 44)">…</g>
  <path data-traveller d="M-7 -6 L8 0 L-7 6 L-4 0 Z" fill="#fff" stroke="#7c3aed"/>
</svg>