โ† Diagrams & maps

Request Flow Diagram

A request travels through your system. A glowing packet hops from service to service, each one lighting up with its timing as the request arrives, then the response runs all the way back in green and the first box shows 200 OK. The boxes snake into rows when they don’t fit on one line.

App CDN Gateway Auth Orders Database

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-request-flow-diagram to a container with one child per service, in the order the request reaches them.
  3. Give a service an emoji with data-icon and a timing with data-time.

Options

Add any of these attributes to the same element.

data-iconOn a service: an emoji shown above its name.
data-timeOn a service: a badge shown when the request reaches it, such as 12 ms.
data-colorColour of the request. Default #a855f7.
data-return-colorColour of the response. Default #10b981.
data-statusBadge on the first box when the response lands. Default 200 OK.
data-one-wayStop at the last service, with no return trip.
data-stepTime for each hop. Default 700 ms.
data-loopSend the request again and again.

The boxes 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 () {
    // Request Flow Diagram: a request travels through a system. A glowing
    // packet runs from service to service, each one lighting up (with its
    // timing) as the request reaches it, then the response runs all the way
    // back in a second colour and the first box shows the status.
    // Usage:
    //   <div data-request-flow-diagram data-loop>
    //     <span data-icon="๐Ÿ“ฑ">App</span>
    //     <span data-icon="๐Ÿšช" data-time="3 ms">Gateway</span>
    //     <span data-icon="โš™๏ธ" data-time="18 ms">Orders</span>
    //     <span data-icon="๐Ÿ—„๏ธ" data-time="9 ms">Database</span>
    //   </div>
    // One child per service, in the order the request reaches them. The boxes
    // snake into rows when they don't fit on one line.
    // Optional: data-icon and data-time on a service; data-color (request,
    // default #a855f7), data-return-color (response, default #10b981),
    // data-status (shown on the first box when the response lands, default
    // "200 OK"), data-one-way (no return trip), data-step (ms per hop,
    // default 700), data-loop (run the request again and again).
    var DEFAULTS = { color: '#a855f7', returnColor: '#10b981', status: '200 OK', step: 700 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.rqf{position:relative;width:100%;--rqf-c:#a855f7;--rqf-r:#10b981}' +
      '.rqf-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
      '.rqf-node{position:absolute;box-sizing:border-box;width:6.4em;padding:.7em .3em .6em;border-radius:.9em;background:#fff;border:2px solid #e6dcf5;text-align:center;line-height:1.15;box-shadow:0 4px 12px rgba(70,30,130,.08);transition:border-color .3s,box-shadow .3s}' +
      '.rqf-icon{display:block;font-size:1.7em;line-height:1.1;margin-bottom:.2em}' +
      '.rqf-name{display:block;font-weight:700;font-size:.92em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
      '.rqf-node.rqf-on{border-color:var(--rqf-c);box-shadow:0 0 0 4px color-mix(in srgb,var(--rqf-c) 18%,transparent),0 6px 18px rgba(70,30,130,.18)}' +
      '.rqf-node.rqf-back{border-color:var(--rqf-r);box-shadow:0 0 0 4px color-mix(in srgb,var(--rqf-r) 18%,transparent),0 6px 18px rgba(20,90,60,.15)}' +
      '.rqf-badge{position:absolute;left:50%;top:0;translate:-50% -60%;padding:.15em .55em;border-radius:99px;background:var(--rqf-c);color:#fff;font-size:.78em;font-weight:700;white-space:nowrap;opacity:0}' +
      '.rqf-badge.rqf-ok{background:var(--rqf-r)}' +
      '.rqf-packet{filter:drop-shadow(0 0 4px currentColor)}';
    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;
      var color = d.color || DEFAULTS.color, back = d.returnColor || DEFAULTS.returnColor;
      var s = (el._rqf = { nodes: [], links: [], visible: false, running: false });
      el.classList.add('rqf');
      el.style.setProperty('--rqf-c', color);
      el.style.setProperty('--rqf-r', back);
      var svg = mk('svg', { class: 'rqf-svg', 'aria-hidden': 'true' });
      s.linkLayer = mk('g', { fill: 'none', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, svg);
      s.packet = mk('circle', { class: 'rqf-packet', r: 5, fill: color, color: color, opacity: 0 }, svg);
      Array.from(el.children).forEach(function (k) {
        var node = document.createElement('div');
        node.className = 'rqf-node';
        if (k.getAttribute('data-icon')) {
          var ic = document.createElement('span');
          ic.className = 'rqf-icon';
          ic.textContent = k.getAttribute('data-icon');
          ic.setAttribute('aria-hidden', 'true');
          node.appendChild(ic);
        }
        var name = document.createElement('span');
        name.className = 'rqf-name';
        name.textContent = k.textContent.trim();
        node.appendChild(name);
        var badge = document.createElement('span');
        badge.className = 'rqf-badge';
        badge.setAttribute('aria-hidden', 'true');
        badge.textContent = k.getAttribute('data-time') || '';
        node.appendChild(badge);
        el.replaceChild(node, k);
        s.nodes.push({ el: node, icon: node.querySelector('.rqf-icon'), badge: badge, time: k.getAttribute('data-time') || '', name: name.textContent });
      });
      for (var i = 0; i < s.nodes.length - 1; i++) {
        s.links.push({
          base: mk('path', { stroke: '#cbbde6', 'stroke-width': 2, 'stroke-dasharray': '4 5' }, s.linkLayer),
          fwd: mk('path', { stroke: color, 'stroke-width': 3 }, s.linkLayer),
          back: mk('path', { stroke: back, 'stroke-width': 3 }, s.linkLayer),
        });
      }
      el.insertBefore(svg, el.firstChild);
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Request flow: ' + s.nodes.map(function (n) { return n.name; }).join(' to ') + (d.oneWay != null ? '' : ', and the response back'));
      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
    }

    // Lay the boxes out in rows that snake back and forth, then route the links.
    function layout(el) {
      var s = el._rqf, n = s.nodes.length;
      var W = el.clientWidth;
      if (!W || !n) return;
      var nw = s.nodes[0].el.offsetWidth, nh = 0;
      s.nodes.forEach(function (x) { nh = Math.max(nh, x.el.offsetHeight); });
      var gap = Math.max(18, nw * 0.42);
      var cols = Math.max(1, Math.min(n, Math.floor((W + gap) / (nw + gap))));
      var rows = Math.ceil(n / cols);
      cols = Math.ceil(n / rows);
      var gx = cols > 1 ? Math.min(nw * 1.1, (W - cols * nw) / (cols - 1)) : 0;
      var gy = Math.max(26, nh * 0.55);
      var left = (W - (cols * nw + (cols - 1) * gx)) / 2;
      var top = 14;
      s.nodes.forEach(function (x, i) {
        var r = Math.floor(i / cols), c = i % cols;
        if (r % 2) c = cols - 1 - c;
        x.x = left + c * (nw + gx);
        x.y = top + r * (nh + gy);
        x.w = nw; x.h = nh; x.row = r;
        x.el.style.left = x.x + 'px';
        x.el.style.top = x.y + 'px';
        x.el.style.height = nh + 'px';
      });
      el.style.height = top + rows * nh + (rows - 1) * gy + 6 + 'px';
      s.links.forEach(function (l, i) {
        var a = s.nodes[i], b = s.nodes[i + 1], p;
        if (a.row === b.row) {
          var dir = b.x > a.x ? 1 : -1, y = a.y + a.h / 2;
          p = 'M' + (dir > 0 ? a.x + a.w + 3 : a.x - 3) + ',' + y + ' L' + (dir > 0 ? b.x - 3 : b.x + b.w + 3) + ',' + y;
        } else {
          var x = a.x + a.w / 2;
          p = 'M' + x + ',' + (a.y + a.h + 3) + ' L' + x + ',' + (b.y - 3);
        }
        l.base.setAttribute('d', p);
        l.fwd.setAttribute('d', p);
        l.back.setAttribute('d', p);
        l.len = l.fwd.getTotalLength();
      });
    }

    function travel(el, link, forward, cb) {
      var s = el._rqf, step = +el.dataset.step || DEFAULTS.step;
      var path = forward ? link.fwd : link.back, len = link.len, o = { t: 0 };
      anime.utils.set(path, { opacity: 1 });
      anime.animate(anime.svg.createDrawable(path), { draw: forward ? ['0 0', '0 1'] : ['1 1', '0 1'], duration: step * 0.7, ease: 'inOut(2)' });
      anime.animate(o, {
        t: 1, duration: step * 0.7, ease: 'inOut(2)',
        onUpdate: function () {
          var p = path.getPointAtLength(len * (forward ? o.t : 1 - o.t));
          s.packet.setAttribute('cx', p.x);
          s.packet.setAttribute('cy', p.y);
        },
        onComplete: cb,
      });
    }

    function light(el, node, cls, badge) {
      node.el.classList.add(cls);
      anime.animate(node.el, { scale: [1, 1.08, 1], duration: 420, ease: 'out(2)' });
      if (node.icon) anime.animate(node.icon, { y: [0, -5, 0], duration: 420, ease: 'out(2)' });
      if (badge) {
        node.badge.textContent = badge;
        anime.animate(node.badge, { opacity: [0, 1], y: [6, 0], duration: 300, ease: 'out(2)' });
      }
    }

    function run(el) {
      var s = el._rqf, d = el.dataset, n = s.nodes.length, step = +d.step || DEFAULTS.step;
      var color = d.color || DEFAULTS.color, back = d.returnColor || DEFAULTS.returnColor;
      s.running = true;
      s.packet.setAttribute('fill', color);
      s.packet.setAttribute('color', color);
      var first = s.nodes[0];
      s.packet.setAttribute('cx', first.x + first.w / 2);
      s.packet.setAttribute('cy', first.y + first.h / 2);
      anime.animate(s.packet, { opacity: [0, 1], r: [0, 5], duration: 250 });
      function forward(i) {
        light(el, s.nodes[i], 'rqf-on', i > 0 ? s.nodes[i].time : '');
        if (i === n - 1) return setTimeout(turn, step * 0.5);
        setTimeout(function () { travel(el, s.links[i], true, function () { forward(i + 1); }); }, step * 0.25);
      }
      function turn() {
        if (d.oneWay != null) return setTimeout(finish, step);
        s.packet.setAttribute('fill', back);
        s.packet.setAttribute('color', back);
        anime.animate(s.packet, { r: [5, 8, 5], duration: 300 });
        s.nodes[n - 1].el.classList.add('rqf-back');
        backward(n - 2);
      }
      function backward(i) {
        travel(el, s.links[i], false, function () {
          var node = s.nodes[i];
          node.el.classList.add('rqf-back');
          if (i === 0) {
            node.badge.classList.add('rqf-ok');
            light(el, node, 'rqf-back', d.status || DEFAULTS.status);
            anime.animate(s.packet, { opacity: 0, r: 12, duration: 400 });
            return setTimeout(finish, step * 2.2);
          }
          anime.animate(node.el, { scale: [1, 1.04, 1], duration: 300 });
          backward(i - 1);
        });
      }
      function finish() {
        anime.animate(s.packet, { opacity: 0, duration: 200 });
        if (d.loop == null) { s.running = false; return; }
        s.links.forEach(function (l) { anime.animate([l.fwd, l.back], { opacity: 0, duration: 500 }); });
        s.nodes.forEach(function (x) {
          x.el.classList.remove('rqf-on', 'rqf-back');
          anime.animate(x.badge, { opacity: 0, duration: 300 });
        });
        setTimeout(function () {
          s.nodes[0].badge.classList.remove('rqf-ok');
          s.running = false;
          if (s.visible) run(el);
        }, 800);
      }
      forward(0);
    }

    function intro(el) {
      var s = el._rqf;
      anime.animate(s.nodes.map(function (x) { return x.el; }), { opacity: [0, 1], scale: [0.6, 1], duration: 500, delay: anime.stagger(90), ease: 'outBack(2)' });
      s.links.forEach(function (l, i) {
        anime.animate(anime.svg.createDrawable(l.base), { draw: ['0 0', '0 1'], duration: 300, delay: 150 + i * 90, ease: 'out(2)' });
      });
      setTimeout(function () { run(el); }, 400 + s.nodes.length * 90);
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._rqf;
          s.visible = e.isIntersecting;
          if (!s.visible) return;
          if (!s.introduced) { s.introduced = true; intro(el); return; }
          if (!s.running) run(el);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-request-flow-diagram]').forEach(function (el) {
        if (el._rqf) return;
        setup(el);
        var s = el._rqf;
        s.links.forEach(function (l) { anime.utils.set([l.fwd, l.back], { opacity: 0 }); });
        if (still) {
          s.nodes.forEach(function (x) { x.el.classList.add('rqf-on'); });
          s.links.forEach(function (l) { anime.utils.set(l.fwd, { opacity: 1 }); });
          return;
        }
        s.nodes.forEach(function (x) { anime.utils.set(x.el, { opacity: 0 }); });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 ยท Your HTML
<div data-request-flow-diagram data-loop>
  <span data-icon="๐Ÿ“ฑ">App</span>
  <span data-icon="๐Ÿšช" data-time="2 ms">Gateway</span>
  <span data-icon="โš™๏ธ" data-time="24 ms">Orders</span>
  <span data-icon="๐Ÿ—„๏ธ" data-time="8 ms">Database</span>
</div>