← Diagrams & maps

Flight Arcs

Flights criss-cross a dotted world map. Each arc lifts off from its city with a bright head, curves over to its destination, and the city ripples as it lands while the trail drains away behind it. Give it your own routes, or let it fly between big cities at random.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-flight-arcs to an empty element. It fills the width and keeps the map’s shape.
  3. List your flights in data-routes as From>To pairs separated by commas, such as data-routes="London>New York, Dubai>Sydney".

Options

Add any of these attributes to the same element.

data-routesFlights to show, in turn, as From>To pairs. Default: random flights between big cities.
data-hubWith no routes: every flight goes to or from this city.
data-labelsShow the city’s name as each flight lands.
data-colorsArc colours, separated by commas. Default #a855f7,#ec4899,#22d3ee.
data-landColour of the map’s dots. Default #c9bfe0.
data-intervalAverage time between take-offs. Default 650 ms.

Places are city names (London, Paris, New York, Tokyo, Sydney, Dubai, Singapore, São Paulo, Cape Town and about 35 more big cities) or a latitude and longitude such as 48.86 2.35. Arcs keep the same thickness at any size; flights pause while the map is off screen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Flight Arcs: a dotted world map with glowing arcs flying between cities.
    // Each arc draws out from its origin with a bright head, the destination
    // ripples as it lands, and the trail drains away behind it.
    // Usage:
    //   <div data-flight-arcs data-routes="London>New York, Dubai>Sydney"></div>
    // Optional: data-routes (comma-separated "From>To" pairs; places are city
    // names from the list below or "lat lon", e.g. "48.86 2.35"; default
    // random flights between big cities), data-hub (with no routes: fly to
    // and from this city only), data-colors (comma-separated arc colours,
    // default #a855f7,#ec4899,#22d3ee), data-land (dot colour, default
    // #c9bfe0), data-interval (ms between take-offs, default 650),
    // data-labels (show the city name as each flight lands).
    var DEFAULTS = { colors: '#a855f7,#ec4899,#22d3ee', land: '#c9bfe0', interval: 650 };
    var NS = 'http://www.w3.org/2000/svg';
    var BIG = ['London', 'New York', 'Tokyo', 'Dubai', 'Singapore', 'Sydney', 'Los Angeles', 'São Paulo', 'Paris',
      'Hong Kong', 'Mumbai', 'Cape Town', 'Toronto', 'Mexico City', 'Nairobi', 'Seoul', 'Moscow', 'Lagos', 'Buenos Aires', 'Cairo'];

    // Simplified coastlines: polygons separated by |, as "lon lat" pairs.
    var LAND = '-168 66 -162 70 -156 71.3 -141 69.6 -128 70 -115 68.8 -95 72 -82 73.5 -80 63 -94 59 -92 57 -82 55 -79 51.5 -76 56 -78 62.5 -70 61 -64 60 -61 56 -56 52 -60 47 -66 45 -70 43.5 -70 41.5 -74 40.5 -76 38 -76 35 -81 31.5 -80 27 -80.5 25.2 -82.5 27.5 -84 30 -89 30.2 -94 29.6 -97.5 27 -97.5 22 -95 19 -91 18.6 -90.5 21 -87 21.5 -88 16 -84 15.5 -83.5 11 -79.5 9 -77.5 8.5 -80 7.5 -83 8.5 -86 11.5 -92 14.5 -96 15.8 -101 17.5 -105.5 20.5 -105.5 23.5 -109 26 -112.8 31.3 -114.7 30 -112 25 -109.5 23 -110.5 24.5 -114 28 -115 30.5 -117 32.5 -120.5 34.5 -122.5 37.5 -124 40.5 -124 46 -123 49 -127.5 50.5 -131 54 -134 58 -140 59.8 -147 60.5 -152 59 -158 56.5 -164 54.6 -158 58.5 -162 60 -165 62.5 -164.5 64.5|-73 78 -60 82 -30 83.5 -18 81 -20 75 -22 70 -32 68 -40 65 -43 60 -48 61 -52 65 -54 69 -58 75.5 -68 76.5|-80 73.5 -68 70 -62 66.5 -65 63 -72 63.5 -78 67 -90 71 -85 73.8|-95 74 -80 76 -75 79 -62 82 -90 82.5 -105 79 -118 76 -110 73 -100 72.5|-118 69 -102 68.5 -101 73 -115 73.5 -125 71.8|-77.5 8.5 -72 12 -64 10.8 -60 8.5 -57 6 -52 5 -50 1.8 -48 -1 -44 -2.5 -38 -4 -35 -7 -35 -9 -39 -13.5 -39 -17.5 -40 -20.5 -42 -23 -46 -24 -48.5 -26 -48.5 -28.5 -53 -33.5 -57 -36 -57 -38.5 -62 -39 -65 -41 -65 -45 -67.5 -46.5 -66 -48 -69 -51 -68.5 -53 -71 -54 -74 -52 -75.5 -48 -73.5 -43 -73.5 -37 -71.5 -31 -70.3 -18.5 -75.5 -15 -78 -10 -81.2 -5.5 -80 -2 -80 1 -77 4 -77.5 6.5|-9.5 37 -9 43 -2 43.5 -1.5 46.5 -4.7 48.5 -1.5 48.7 1.5 50.5 4 51.5 5 53.3 8.5 53.8 8.5 57 10.5 57.7 10.5 54.5 14 54 19 54.5 21.5 57.5 24 57.3 23.5 59.3 29 60 22.5 60.5 21.5 63 25.5 65 22 65.8 17.5 62.5 19 60 16.5 56.5 13 55.5 11 58.5 8 58 5.5 58.8 5 62 10 64 14 67.5 19 70 25 71 31 70 40 67.5 41 66 34 64.6 37 64.2 44 66 44 68.5 53 68.5 59 68.5 67 69 69 72.8 73 72.5 72 66.5 78 72.5 83 70.5 87 73.8 100 76 105 77.7 113 73.7 127 73.5 131 71 140 72.5 150 71.5 160 70.6 170 70 180 69 180 65 178.5 64.5 175 62 170 60 163 59.8 163.5 56 156.7 51 155.5 57.5 162 61.7 155 59.3 143 59.4 136 54.5 141 52.5 140 48 135 43.5 131 42.6 129.5 40.5 129.5 36 126.5 34.5 126 37.7 124.5 39.8 121.5 39 122 40.5 118 39.2 119 37 122.5 37 120 34.5 121.8 31.5 122 29 118.5 24.5 113.5 22.2 109.5 21.5 108 21.5 106.5 20 105.8 18.5 108.5 15 109.3 11.5 105 8.6 104.7 10.5 100.5 13.5 99.2 10 100.5 7.2 103.5 1.3 101 2.8 98.5 8 98.3 12.5 97.5 16.5 94.5 16 94 19 92 22 90 22 86.5 20 85 19.5 80.3 15.5 80 10.3 77.5 8 76 10 73 17 72.5 21 69 22.5 67 24.5 62 25.2 57 25.7 56.3 27 54 26.8 51.5 27.9 50 30 48 30 48.5 28 51.5 24.5 55 22.5 58.8 22.5 59.8 22 57 18.8 52 16 45 12.8 43 13 42.6 15.5 39 21.5 35.5 27.5 32.6 30 34.5 31.8 35 32.5 36 35.8 32 36.2 29 36.7 26.5 39.5 29 41 28 42 28.5 43.5 30 45.5 33.5 44.5 36 45.3 39 47 38 45 41.5 41.5 36 41.5 33 42 29 41.2 26 40.8 23 40.2 24 38 22 36.5 21 38.5 19.5 41.5 19 42.5 13.5 45.5 12.3 44.5 16 41.5 18.5 40.2 16 38 15.7 40 12 42 10 44 8.5 44.4 6 43.1 3 43.3 3.3 41.9 0 39.5 -0.5 38 -2 36.7 -5.3 36 -6.5 36.9 -8.8 37|-6 35.8 -2 35.1 3 36.8 10 37.3 11 35.5 10 33.8 15 32.3 20 30.8 20 32.5 25 31.8 29 30.9 32.3 31.3 34 27.5 37 22 38.5 18 43 12.6 44 10.4 51.2 11.8 51 10.4 49 6 46 2 42 -1 40 -3 39 -6.5 40.5 -10.5 40.5 -15 37 -17.5 35.5 -21 35.5 -24 32.8 -26 32.5 -28.6 30 -31.3 27 -33.6 22 -34.1 18.5 -34.2 18 -31.5 15 -27 14.5 -22.5 11.8 -17 13.5 -12 12 -6 9 -1 9.5 3.8 8 4.4 5.5 4.2 3 6.3 -2 4.8 -7.5 4.4 -12 7 -15 10.8 -17.1 14.7 -16 19 -16.5 22 -13 27.5 -10 29.5 -9.5 32.5|44 -25 47 -25 50.5 -15.5 49.3 -12 44.5 -16 43.3 -22|-5.7 50 1.5 51.2 1.7 52.7 -0.5 54.5 -2 55.9 -1.8 57.6 -3 58.6 -5 58.6 -6.2 56.5 -5 55 -3 54 -4.6 53.3 -5 51.6|-10 51.6 -6 52 -6 54 -7.5 55.3 -10 54.2|-24 65.5 -22 66.4 -15 66.5 -13.5 65 -18 63.4 -22.5 63.8|130 31 131.5 31.5 132 33.8 135 33.5 136.8 34.5 140 35 141 38 142 40 141.4 41.4 140 40.3 139.8 38 137 37 133 35.5 130.9 34|140 41.5 141.8 42.5 145.5 43.3 142 45.5|79.8 6 81.8 7.5 80 9.8|95.3 5.6 98 4 104 -2 106 -5.8 102 -4 98.5 1|109 2 111 1 116.5 7 119 5 117.5 1 116.3 -3.5 114.5 -4 110.5 -3|105.5 -6.5 114.5 -7.7 114.4 -8.7 108 -7.8|119 -5.5 120.5 0.7 125 1.5 121 -1 123 -4.5|131 -1.3 134 -0.8 138 -1.7 145 -4.5 151 -10.5 147 -10.2 143 -9 141 -9.2 138 -8.3 137.5 -5 133 -4|120 18.5 122.3 18.4 124.3 12.5 126.5 7 125.5 5.6 122 7 123.5 10 121 13.5 120.5 16|113.5 -22 114 -26 115 -34 118 -35 123.5 -33.8 129 -31.6 131.5 -31.5 135 -34.8 138 -35.5 140 -37.8 144 -38.5 146.3 -39 150 -37.5 151.3 -34 153.5 -28.5 153 -25.5 149 -21 145.5 -15 143.5 -14 142.5 -10.7 141.5 -13 141.5 -16.5 140 -17.5 136 -15 136.8 -12.2 132.5 -11.5 130 -13.3 129.5 -15 127 -14 124 -16.3 122 -18 119 -20 116 -21|144.6 -40.7 148.3 -40.9 148 -43 146 -43.6|172.7 -34.5 178.5 -37.7 176.8 -40 174.8 -41.4 173 -39.3 174.5 -37|172.5 -40.6 174 -41.7 171 -44.3 169 -46.7 166.5 -46 168.3 -44|-85 21.8 -81 23.1 -77 21 -74.2 20.2 -77.5 19.9 -80.5 21.8|-74.4 18.5 -72.8 19.9 -68.4 19 -70 18.2';

    var CITIES = {
      'London': [-0.13, 51.5], 'Paris': [2.35, 48.86], 'Berlin': [13.4, 52.52], 'Madrid': [-3.7, 40.42],
      'Rome': [12.5, 41.9], 'Amsterdam': [4.9, 52.37], 'Stockholm': [18.07, 59.33], 'Moscow': [37.6, 55.75],
      'Istanbul': [28.98, 41.01], 'Reykjavik': [-21.9, 64.15], 'Cairo': [31.24, 30.04], 'Lagos': [3.38, 6.52],
      'Nairobi': [36.82, -1.29], 'Johannesburg': [28.05, -26.2], 'Cape Town': [18.42, -33.92], 'Dubai': [55.27, 25.2],
      'Mumbai': [72.88, 19.08], 'Delhi': [77.2, 28.6], 'Bangkok': [100.5, 13.75], 'Singapore': [103.82, 1.35],
      'Jakarta': [106.85, -6.2], 'Hong Kong': [114.17, 22.32], 'Shanghai': [121.47, 31.23], 'Beijing': [116.4, 39.9],
      'Seoul': [126.98, 37.57], 'Tokyo': [139.69, 35.69], 'Sydney': [151.21, -33.87], 'Melbourne': [144.96, -37.81],
      'Auckland': [174.76, -36.85], 'Honolulu': [-157.86, 21.3], 'Vancouver': [-123.12, 49.28], 'Seattle': [-122.33, 47.6],
      'San Francisco': [-122.42, 37.77], 'Los Angeles': [-118.24, 34.05], 'Mexico City': [-99.13, 19.43], 'Chicago': [-87.63, 41.88],
      'Toronto': [-79.38, 43.65], 'New York': [-74.0, 40.71], 'Miami': [-80.19, 25.76], 'Bogotá': [-74.07, 4.71],
      'Lima': [-77.04, -12.05], 'São Paulo': [-46.63, -23.55], 'Rio de Janeiro': [-43.17, -22.9], 'Buenos Aires': [-58.38, -34.6]
    };

    function plain(t) { return t.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); }

    // A city name from the list above, or "lat lon" such as "48.86 2.35".
    // Returns [lon, lat, name] or null.
    function place(text) {
      text = text.trim();
      var m = /^(-?[\d.]+)[\s,]+(-?[\d.]+)$/.exec(text);
      if (m) return [+m[2], +m[1], ''];
      for (var k in CITIES) if (plain(k) === plain(text)) return [CITIES[k][0], CITIES[k][1], k];
      return null;
    }

    var css =
      '.fta{position:relative;width:100%;line-height:1.2}' +
      '.fta-svg{display:block;width:100%;height:auto;overflow:hidden}' +
      '.fta-label{position:absolute;translate:-50% -140%;white-space:nowrap;padding:.15em .5em;border-radius:99px;background:#fff;color:#2a1d40;font-weight:700;font-size:.8em;box-shadow:0 .15em .5em rgba(40,20,80,.18);pointer-events:none;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    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;
    }

    // Equirectangular: x 0-360 for lon -180-180, y 0-142 for lat 84 to -58.
    function px(p) { return [p[0] + 180, 84 - p[1]]; }

    function landPath() {
      return LAND.split('|').map(function (poly) {
        var n = poly.split(' ').map(Number), d = '';
        for (var i = 0; i < n.length; i += 2) d += (i ? 'L' : 'M') + (n[i] + 180).toFixed(1) + ' ' + (84 - n[i + 1]).toFixed(1);
        return d + 'Z';
      }).join('');
    }

    function setup(el) {
      var d = el.dataset, id = 'fta-dots-' + ++uid;
      var s = (el._fta = { visible: false, timer: 0, n: 0, c: 0, markers: {} });
      el.classList.add('fta');
      s.colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      s.interval = +d.interval || DEFAULTS.interval;
      s.routes = [];
      (d.routes || '').split(',').forEach(function (r) {
        var ends = r.split('>');
        if (ends.length !== 2) return;
        var a = place(ends[0]), b = place(ends[1]);
        if (a && b) s.routes.push([a, b]);
      });
      if (d.hub) s.hub = place(d.hub);
      var svg = (s.svg = mk('svg', { class: 'fta-svg', viewBox: '0 0 360 142', 'aria-hidden': 'true' }));
      var pat = mk('pattern', { id: id, width: 2.4, height: 2.4, patternUnits: 'userSpaceOnUse' }, mk('defs', {}, svg));
      mk('circle', { cx: 1.2, cy: 1.2, r: 0.62, fill: d.land || DEFAULTS.land }, pat);
      mk('path', { d: landPath(), fill: 'url(#' + id + ')' }, svg);
      s.arcs = mk('g', { fill: 'none', 'stroke-linecap': 'round' }, svg);
      s.dots = mk('g', {}, svg);
      el.appendChild(svg);
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) {
        el.setAttribute('aria-label', s.routes.length
          ? 'Map of flights: ' + s.routes.map(function (r) { return (r[0][2] || 'here') + ' to ' + (r[1][2] || 'there'); }).join(', ')
          : 'World map with flights arcing between cities');
      }
      scale(el);
      new ResizeObserver(function () { scale(el); }).observe(el);
    }

    // Viewbox units per screen pixel, so lines keep their thickness at any size.
    function scale(el) {
      var s = el._fta, w = s.svg.clientWidth || 300;
      s.k = 360 / w;
      Object.keys(s.markers).forEach(function (key) {
        var m = s.markers[key];
        m.dot.setAttribute('r', 2.6 * s.k);
        m.dot.setAttribute('stroke-width', 1.2 * s.k);
      });
    }

    function marker(el, p, color) {
      var s = el._fta, key = p[0] + ',' + p[1];
      if (!s.markers[key]) {
        var xy = px(p);
        var ring = mk('circle', { cx: xy[0], cy: xy[1], r: 0, fill: 'none', 'stroke-width': 1.4 * s.k, opacity: 0 }, s.dots);
        var dot = mk('circle', { cx: xy[0], cy: xy[1], r: 2.6 * s.k, fill: color, stroke: '#fff', 'stroke-width': 1.2 * s.k }, s.dots);
        s.markers[key] = { dot: dot, ring: ring, p: p };
      }
      return s.markers[key];
    }

    function pulse(el, m, color, big) {
      var k = el._fta.k;
      m.dot.setAttribute('fill', color);
      m.ring.setAttribute('stroke', color);
      m.ring.setAttribute('stroke-width', 1.4 * k);
      anime.animate(m.ring, { r: [2 * k, (big ? 16 : 9) * k], opacity: [0.9, 0], duration: big ? 1100 : 800, ease: 'out(3)' });
      anime.animate(m.dot, { r: [2.6 * k, 4.2 * k, 2.6 * k], duration: 500, ease: 'out(2)' });
    }

    function label(el, p) {
      if (el.dataset.labels === undefined || !p[2] || el.closest('[aria-hidden="true"]')) return;
      var xy = px(p), tag = document.createElement('span');
      tag.className = 'fta-label';
      tag.setAttribute('aria-hidden', 'true');
      tag.textContent = p[2];
      tag.style.top = (xy[1] / 1.42) + '%';
      el.appendChild(tag);
      var w = el.clientWidth, half = tag.offsetWidth / 2 + 2;
      tag.style.left = Math.max(half, Math.min(w - half, xy[0] / 360 * w)) + 'px';
      anime.createTimeline({ onComplete: function () { tag.remove(); } })
        .add(tag, { opacity: [0, 1], y: [6, 0], duration: 300, ease: 'out(3)' })
        .add(tag, { opacity: 0, duration: 400, ease: 'in(2)' }, 1700);
    }

    function nextRoute(el) {
      var s = el._fta;
      if (s.routes.length) return s.routes[s.n++ % s.routes.length];
      var pick = function () { return place(BIG[Math.floor(Math.random() * BIG.length)]); };
      var a = s.hub || pick(), b = pick();
      while (b[2] === a[2]) b = pick();
      if (s.hub && Math.random() < 0.5) return [b, a];
      return [a, b];
    }

    function arcPath(a, b) {
      var A = px(a), B = px(b), dx = B[0] - A[0], dy = B[1] - A[1], dist = Math.sqrt(dx * dx + dy * dy);
      var lift = Math.min(46, 4 + dist * 0.34);
      var cx = (A[0] + B[0]) / 2, cy = Math.max(-20, (A[1] + B[1]) / 2 - lift);
      return { d: 'M' + A[0] + ' ' + A[1] + 'Q' + cx + ' ' + cy + ' ' + B[0] + ' ' + B[1], dist: dist };
    }

    function fly(el, route, still) {
      var s = el._fta, k = s.k, color = s.colors[s.c++ % s.colors.length];
      var arc = arcPath(route[0], route[1]);
      var glow = mk('path', { d: arc.d, stroke: color, 'stroke-width': 5 * k, opacity: 0.18 }, s.arcs);
      var line = mk('path', { d: arc.d, stroke: color, 'stroke-width': 1.8 * k }, s.arcs);
      var from = marker(el, route[0], color), to = marker(el, route[1], color);
      if (still) return;
      var len = line.getTotalLength();
      var head = mk('circle', { r: 3 * k, fill: '#fff', stroke: color, 'stroke-width': 1.6 * k }, s.arcs);
      [glow, line].forEach(function (p) { p.setAttribute('stroke-dasharray', len + ' ' + len); p.setAttribute('stroke-dashoffset', len); });
      pulse(el, from, color, false);
      var st = { p: 0 };
      function draw(off) { glow.setAttribute('stroke-dashoffset', off); line.setAttribute('stroke-dashoffset', off); }
      anime.animate(st, {
        p: 1, duration: 900 + arc.dist * 9, ease: 'inOut(2)',
        onUpdate: function () {
          draw(len * (1 - st.p));
          var pt = line.getPointAtLength(len * st.p);
          head.setAttribute('cx', pt.x);
          head.setAttribute('cy', pt.y);
        },
        onComplete: function () {
          head.remove();
          pulse(el, to, color, true);
          label(el, route[1]);
          var tail = { q: 0 };
          anime.animate(tail, {
            q: 1, duration: 800, delay: 350, ease: 'in(2)',
            onUpdate: function () { draw(-len * tail.q); },
            onComplete: function () { glow.remove(); line.remove(); },
          });
        },
      });
    }

    function tick(el) {
      var s = el._fta;
      s.timer = 0;
      if (!s.visible) return;
      if (s.arcs.childNodes.length < 18) fly(el, nextRoute(el));
      s.timer = setTimeout(function () { tick(el); }, s.interval * (0.7 + Math.random() * 0.6));
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._fta;
          s.visible = e.isIntersecting;
          if (s.visible && !s.timer) tick(e.target);
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-flight-arcs]').forEach(function (el) {
        if (el._fta) return;
        setup(el);
        if (reduced()) {
          var s = el._fta, count = s.routes.length || 6;
          for (var i = 0; i < count; i++) fly(el, nextRoute(el), true);
          return;
        }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-flight-arcs data-labels
     data-routes="London>New York, Dubai>Sydney, Tokyo>Los Angeles"></div>