← Diagrams & maps

Map Pin Drop

Pins fall onto your map one after another. Each lands with a squash and a little bounce, its shadow firming up beneath it, and a ripple spreads out across the map from where it hit. Then its label pops up beside it.

Bakery Park café Bookshop Gallery Market

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-map-pin-drop to a container. Put the map first (an <img> or inline SVG), then one child per pin.
  3. Place each pin’s point with data-x and data-y, from 0 to 100 across and down the map. Its text, if any, becomes its label.

Options

Add any of these attributes to the same element.

data-x / data-yOn a pin: where its point lands, 0–100 from the top left.
data-colorPin colour, on the container or on a single pin. Default #7c3aed.
data-staggerGap between pins. Default 380 ms.
data-loopLift the pins off and drop them again, in a new order.

Labels sit to the right of their pin, or to the left near the right-hand edge. Pins and labels are sized in em, so set font-size on the container to scale them.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Map Pin Drop: pins fall onto a map one after another, land with a
    // squash and a bounce, and send a ripple out across the map. Their labels
    // pop up beside them.
    // Usage:
    //   <div data-map-pin-drop>
    //     <img src="map.png" alt="Map of the town centre">
    //     <span data-x="30" data-y="40">Café</span>
    //     <span data-x="72" data-y="65"></span>
    //   </div>
    // The picture comes first, then one child per pin. data-x and data-y place
    // the pin's point, 0-100 across and down the picture. Its text (if any) is
    // the label.
    // Optional: data-color (pin colour, default #7c3aed; also on a single
    // pin), data-stagger (ms between pins, default 380), data-loop (lift the
    // pins off and drop them again).
    var DEFAULTS = { color: '#7c3aed', stagger: 380 };

    var css =
      '.mpd{position:relative;display:block;line-height:0;overflow:hidden;border-radius:.8em}' +
      '.mpd>img,.mpd>svg{display:block;width:100%;height:auto}' +
      '.mpd-spot{position:absolute;width:0;height:0;line-height:1.2}' +
      '.mpd-pin{position:absolute;left:-.8em;bottom:0;width:1.6em;height:2.2em;transform-origin:50% 100%;opacity:0;filter:drop-shadow(0 .12em .1em rgba(30,10,60,.25))}' +
      '.mpd-pin svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.mpd-shadow{position:absolute;left:-.55em;top:-.18em;width:1.1em;height:.36em;border-radius:50%;background:rgba(30,10,60,.3);opacity:0}' +
      '.mpd-ripple{position:absolute;left:-1em;top:-1em;width:2em;height:2em;border-radius:50%;border:.14em solid var(--mpd-c);box-sizing:border-box;opacity:0}' +
      '.mpd-label{position:absolute;left:1em;bottom:1.2em;white-space:nowrap;padding:.25em .6em;border-radius:.5em;background:#fff;color:#1f1530;font-weight:700;font-size:.85em;box-shadow:0 .2em .7em rgba(30,10,60,.18);opacity:0;transform-origin:0 100%}' +
      '.mpd-spot.mpd-left .mpd-label{left:auto;right:1em;transform-origin:100% 100%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function pinSvg(color) {
      return '<svg viewBox="0 0 32 44" aria-hidden="true"><path d="M16 43C16 43 2 26.5 2 16a14 14 0 0 1 28 0c0 10.5-14 27-14 27z" fill="' + color +
        '" stroke="#fff" stroke-width="2"/><circle cx="16" cy="16" r="5.5" fill="#fff"/></svg>';
    }

    function setup(el) {
      var d = el.dataset;
      var s = (el._mpd = { pins: [] });
      el.classList.add('mpd');
      Array.from(el.children).slice(1).forEach(function (k) {
        var color = k.getAttribute('data-color') || d.color || DEFAULTS.color;
        var x = parseFloat(k.getAttribute('data-x')), y = parseFloat(k.getAttribute('data-y'));
        var text = k.textContent.trim();
        var spot = document.createElement('div');
        spot.className = 'mpd-spot' + (x > 62 ? ' mpd-left' : '');
        spot.style.left = (isNaN(x) ? 50 : x) + '%';
        spot.style.top = (isNaN(y) ? 50 : y) + '%';
        spot.style.setProperty('--mpd-c', color);
        spot.innerHTML = '<span class="mpd-ripple" aria-hidden="true"></span><span class="mpd-shadow" aria-hidden="true"></span><span class="mpd-pin">' + pinSvg(color) + '</span>';
        if (text) {
          var label = document.createElement('span');
          label.className = 'mpd-label';
          label.textContent = text;
          spot.appendChild(label);
        }
        el.replaceChild(spot, k);
        s.pins.push({
          spot: spot, pin: spot.querySelector('.mpd-pin'), shadow: spot.querySelector('.mpd-shadow'),
          ripple: spot.querySelector('.mpd-ripple'), label: spot.querySelector('.mpd-label'),
        });
      });
    }

    function drop(el, p, delay) {
      var fall = Math.max(60, p.spot.offsetTop + 40);
      anime.utils.set(p.pin, { opacity: 1, y: -fall, scaleX: 1, scaleY: 1 });
      anime.utils.set(p.shadow, { y: 0 });
      var tl = anime.createTimeline({ delay: delay });
      tl.add(p.pin, { y: [-fall, 0], duration: 480, ease: 'in(3)' }, 0)
        .add(p.shadow, { opacity: [0, 1], scale: [0.2, 1], duration: 480, ease: 'in(2)' }, 0)
        .add(p.pin, { scaleY: [1, 0.72, 1.08, 1], scaleX: [1, 1.2, 0.95, 1], duration: 520, ease: 'out(2)' }, 480)
        .add(p.pin, { y: [0, -14, 0, -4, 0], duration: 620, ease: 'out(2)' }, 520)
        .add(p.shadow, { scale: [1, 0.7, 1, 0.9, 1], duration: 620, ease: 'out(2)' }, 520)
        .add(p.ripple, { scale: [0.2, 2.6], opacity: [0.9, 0], duration: 900, ease: 'out(3)' }, 470)
        .add(p.ripple, { scale: [0.2, 2], opacity: [0.6, 0], duration: 900, ease: 'out(3)' }, 700);
      if (p.label) tl.add(p.label, { opacity: [0, 1], scale: [0.6, 1], y: [6, 0], duration: 420, ease: 'outBack(2)' }, 760);
      return tl;
    }

    function play(el) {
      var s = el._mpd, stagger = +el.dataset.stagger || DEFAULTS.stagger;
      var order = s.pins.slice();
      if (s.ran) order.sort(function () { return Math.random() - 0.5; });
      s.ran = true;
      order.forEach(function (p, i) { drop(el, p, 250 + i * stagger); });
      if (el.dataset.loop === undefined) return;
      var total = 250 + order.length * stagger + 2600;
      setTimeout(function () {
        s.pins.forEach(function (p, i) {
          var parts = [p.pin, p.shadow].concat(p.label ? [p.label] : []);
          anime.animate(parts, { opacity: 0, y: '-=12', duration: 360, delay: i * 60, ease: 'in(2)' });
        });
        setTimeout(function () { play(el); }, 360 + s.pins.length * 60 + 300);
      }, total);
    }

    function still(el) {
      el._mpd.pins.forEach(function (p) {
        p.pin.style.opacity = 1;
        p.shadow.style.opacity = 1;
        if (p.label) p.label.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-map-pin-drop]').forEach(function (el) {
        if (el._mpd) 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-map-pin-drop style="max-width: 600px">
  <img src="map.png" alt="Map of the town centre">
  <span data-x="27" data-y="28">Bakery</span>
  <span data-x="44" data-y="62" data-color="#db2777">Bookshop</span>
  <span data-x="82" data-y="74">Gallery</span>
</div>