← Diagrams & maps

Force-directed Graph

A network that finds its own shape. The nodes burst out from the middle and spring into place, pushed apart by each other and held together by elastic links that thin out as they stretch. Drag a node (or let it tug one for you) and the rest follow it, wobble and settle.

You Ana Ben Cleo Dev Eli Fay Gus Hana Ivo Jo Kai Lou

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-force-directed-graph to a container with a height, with one child per node.
  3. Give a node data-id to name it, and data-links to list the nodes it joins, comma-separated. With no children, data-nodes makes a random graph.

Options

Add any of these attributes to the same element.

data-linksOn a node: the ids (or names) of the nodes it links to.
data-idOn a node: its id. Default: its text.
data-nodesWith no children: how many random nodes to make. Default 14.
data-colorsComma-separated colours. Each node takes the colour of the hub it is nearest. Default purple, pink, blue, green, amber.
data-link-colorColour of the links. Default #a78bfa.
data-lengthResting length of a link, in px. Default: fits the space.
data-autoplayTug a random node every few seconds, until someone drags one.
data-loopPull everything back in and spring out again every 12 seconds.

Bigger nodes have more links. Keep labels short; the graph keeps them inside the box.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Force-directed Graph: nodes burst out from the middle and spring into
    // place, held together by elastic links that stretch thin and relax. Drag a
    // node and the rest follow it about, then settle again.
    // Usage:
    //   <div data-force-directed-graph style="height: 320px">
    //     <span data-id="ana">Ana</span>
    //     <span data-links="ana">Ben</span>
    //     <span data-links="ana,ben">Cleo</span>
    //   </div>
    // Each child is a node. data-id names it (default: its text) and
    // data-links lists the nodes it joins, comma-separated. With no children,
    // data-nodes makes a random graph of that many nodes.
    // Optional: data-nodes (random graph size, default 14), data-colors
    // (comma-separated cluster colours), data-link-color (default #a78bfa),
    // data-length (resting link length in px, default: fits the space),
    // data-autoplay (tug a random node every few seconds until someone drags
    // one), data-loop (pull everything back in and spring out again every
    // 12 s).
    var DEFAULTS = { nodes: 14, colors: '#7c3aed,#db2777,#2563eb,#059669,#d97706', linkColor: '#a78bfa', loopEvery: 12000, tugEvery: 2600 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      ':where(.fdg){position:relative;width:100%;height:320px}' +
      '.fdg{user-select:none;-webkit-user-select:none}' +
      '.fdg-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.fdg-hit{fill:transparent;cursor:grab;touch-action:none}' +
      '.fdg-node{stroke:#fff;stroke-width:2;filter:drop-shadow(0 2px 4px rgba(60,20,110,.25))}' +
      '.fdg-label{font:600 11px/1 system-ui,sans-serif;fill:#3b2a55;text-anchor:middle;paint-order:stroke;stroke:rgba(255,255,255,.9);stroke-width:3px;stroke-linejoin:round;pointer-events:none}' +
      '.fdg-drag .fdg-hit{cursor:grabbing}';
    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;
    }

    // Read nodes and links from the children, or make a random graph.
    function readGraph(el) {
      var kids = Array.from(el.children), nodes = [], links = [], byId = {};
      if (kids.length) {
        kids.forEach(function (k, i) {
          var label = k.textContent.trim();
          var id = (k.getAttribute('data-id') || label || String(i)).toLowerCase();
          var n = { id: id, label: label, deg: 0 };
          byId[id] = n;
          nodes.push(n);
        });
        kids.forEach(function (k, i) {
          (k.getAttribute('data-links') || '').split(',').forEach(function (t) {
            t = t.trim().toLowerCase();
            if (t && byId[t] && byId[t] !== nodes[i]) links.push({ a: nodes[i], b: byId[t] });
          });
          k.remove();
        });
      } else {
        var count = Math.max(3, +el.dataset.nodes || DEFAULTS.nodes);
        for (var i = 0; i < count; i++) nodes.push({ id: String(i), label: '', deg: 0 });
        // A few hubs, every other node hangs off an earlier one, plus a few
        // cross links so it isn't a pure tree.
        var hubs = Math.max(1, Math.round(count / 6));
        for (i = 1; i < count; i++) {
          var j = i < hubs ? 0 : Math.floor(Math.random() * Math.min(i, hubs + 2));
          if (i >= hubs && Math.random() < 0.35) j = Math.floor(Math.random() * i);
          links.push({ a: nodes[i], b: nodes[j] });
        }
        for (i = 0; i < Math.round(count / 5); i++) {
          var a = nodes[Math.floor(Math.random() * count)], b = nodes[Math.floor(Math.random() * count)];
          if (a !== b) links.push({ a: a, b: b });
        }
      }
      links.forEach(function (l) { l.a.deg++; l.b.deg++; });
      return { nodes: nodes, links: links };
    }

    // Colour each node by the hub it is closest to.
    function colourNodes(g, colors) {
      var adj = new Map();
      g.nodes.forEach(function (n) { adj.set(n, []); });
      g.links.forEach(function (l) { adj.get(l.a).push(l.b); adj.get(l.b).push(l.a); });
      var hubs = g.nodes.slice().sort(function (a, b) { return b.deg - a.deg; }).slice(0, Math.min(colors.length, Math.max(1, Math.round(g.nodes.length / 5))));
      var queue = [];
      hubs.forEach(function (h, i) { h.group = i; queue.push(h); });
      while (queue.length) {
        var n = queue.shift();
        adj.get(n).forEach(function (m) { if (m.group == null) { m.group = n.group; queue.push(m); } });
      }
      g.nodes.forEach(function (n) { n.color = colors[(n.group || 0) % colors.length]; });
    }

    function setup(el) {
      var d = el.dataset;
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var g = readGraph(el);
      colourNodes(g, colors);
      var s = (el._fdg = { g: g, visible: false, raf: 0, last: 0, drag: null, userTouched: false });
      el.classList.add('fdg');
      var svg = mk('svg', { class: 'fdg-svg', 'aria-hidden': 'true' });
      var lg = mk('g', { stroke: d.linkColor || DEFAULTS.linkColor, 'stroke-linecap': 'round' }, svg);
      var ng = mk('g', {}, svg);
      g.links.forEach(function (l) { l.line = mk('line', {}, lg); });
      g.nodes.forEach(function (n) {
        n.r = (g.nodes.length > 20 ? 4 : 6) + Math.sqrt(n.deg) * 2.2;
        n.g = mk('g', {}, ng);
        n.dot = mk('circle', { class: 'fdg-node', r: n.r, fill: n.color }, n.g);
        n.hit = mk('circle', { class: 'fdg-hit', r: Math.max(n.r + 6, 16) }, n.g);
        if (n.label) n.text = mk('text', { class: 'fdg-label', y: n.r + 13 }, n.g);
        if (n.text) n.text.textContent = n.label;
        n.vx = n.vy = 0;
        n.hit.addEventListener('pointerdown', function (e) { grab(el, n, e); });
      });
      el.appendChild(svg);
      s.svg = svg;
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) {
        var named = g.nodes.filter(function (n) { return n.label; });
        el.setAttribute('aria-label', named.length
          ? 'Network graph: ' + g.links.map(function (l) { return l.a.label + ' to ' + l.b.label; }).join(', ')
          : 'Network graph of ' + g.nodes.length + ' linked nodes');
      }
      measure(el);
      scatter(el, 0.02);
      new ResizeObserver(function () {
        var w0 = s.w, h0 = s.h;
        measure(el);
        if (w0 && h0) g.nodes.forEach(function (n) { n.x *= s.w / w0; n.y *= s.h / h0; });
        if (!s.raf) draw(el);
      }).observe(el);
      if (d.autoplay != null) el.addEventListener('pointerdown', function () { s.userTouched = true; }, true);
    }

    function measure(el) {
      var s = el._fdg, n = s.g.nodes.length;
      s.w = el.clientWidth || 300;
      s.h = el.clientHeight || 200;
      s.len = +el.dataset.length || Math.max(26, Math.min(110, Math.sqrt((s.w * s.h) / n) * 0.62));
    }

    // Put every node near the middle, ready to burst out.
    function scatter(el, spread) {
      var s = el._fdg;
      s.g.nodes.forEach(function (n) {
        var a = Math.random() * Math.PI * 2, r = Math.random() * Math.min(s.w, s.h) * spread;
        n.x = s.w / 2 + Math.cos(a) * r;
        n.y = s.h / 2 + Math.sin(a) * r;
        n.vx = Math.cos(a) * 2;
        n.vy = Math.sin(a) * 2;
      });
      s.heat = 1;
    }

    function step(el) {
      var s = el._fdg, nodes = s.g.nodes, links = s.g.links, L = s.len;
      var i, j, a, b, dx, dy, dist, f;
      for (i = 0; i < nodes.length; i++) { nodes[i].fx = 0; nodes[i].fy = 0; }
      // Every pair pushes apart.
      for (i = 0; i < nodes.length; i++) {
        a = nodes[i];
        for (j = i + 1; j < nodes.length; j++) {
          b = nodes[j];
          dx = a.x - b.x; dy = a.y - b.y;
          dist = Math.max(1, dx * dx + dy * dy);
          f = (L * L * 0.55) / dist;
          if (f > 6) f = 6;
          var rd = Math.sqrt(dist);
          a.fx += (dx / rd) * f; a.fy += (dy / rd) * f;
          b.fx -= (dx / rd) * f; b.fy -= (dy / rd) * f;
        }
      }
      // Links pull like springs.
      links.forEach(function (l) {
        dx = l.b.x - l.a.x; dy = l.b.y - l.a.y;
        dist = Math.sqrt(dx * dx + dy * dy) || 1;
        f = (dist - L) * 0.045;
        l.a.fx += (dx / dist) * f; l.a.fy += (dy / dist) * f;
        l.b.fx -= (dx / dist) * f; l.b.fy -= (dy / dist) * f;
        l.stretch = dist / L;
      });
      // A gentle pull to the middle, then move with a little bounce.
      var pad = 16;
      nodes.forEach(function (n) {
        n.fx += (s.w / 2 - n.x) * 0.006;
        n.fy += (s.h / 2 - n.y) * 0.009;
        if (s.drag && s.drag.node === n) return;
        n.vx = (n.vx + n.fx) * 0.86;
        n.vy = (n.vy + n.fy) * 0.86;
        var sp = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
        if (sp > 18) { n.vx *= 18 / sp; n.vy *= 18 / sp; }
        n.x += n.vx; n.y += n.vy;
        var bot = s.h - pad - (n.text ? 14 : 0);
        if (n.x < pad) { n.x = pad; n.vx *= -0.5; }
        if (n.x > s.w - pad) { n.x = s.w - pad; n.vx *= -0.5; }
        if (n.y < pad) { n.y = pad; n.vy *= -0.5; }
        if (n.y > bot) { n.y = bot; n.vy *= -0.5; }
      });
    }

    function draw(el) {
      var s = el._fdg;
      s.g.links.forEach(function (l) {
        var st = l.stretch || 1;
        l.line.setAttribute('x1', l.a.x.toFixed(1));
        l.line.setAttribute('y1', l.a.y.toFixed(1));
        l.line.setAttribute('x2', l.b.x.toFixed(1));
        l.line.setAttribute('y2', l.b.y.toFixed(1));
        // Stretched links go thin, slack ones thicken.
        l.line.setAttribute('stroke-width', Math.max(0.8, Math.min(3.2, 2 / st)).toFixed(2));
        l.line.setAttribute('stroke-opacity', Math.max(0.35, Math.min(0.9, 0.95 / st)).toFixed(2));
      });
      s.g.nodes.forEach(function (n) { n.g.setAttribute('transform', 'translate(' + n.x.toFixed(1) + ',' + n.y.toFixed(1) + ')'); });
    }

    function frame(el, t) {
      var s = el._fdg;
      s.raf = 0;
      if (!s.visible) return;
      step(el);
      draw(el);
      var d = el.dataset;
      if (d.autoplay != null && !s.userTouched && !s.drag && t - (s.lastTug || 0) > DEFAULTS.tugEvery && t - s.start > 2200) {
        s.lastTug = t;
        tug(el);
      }
      if (d.loop != null && t - s.start > DEFAULTS.loopEvery && !s.drag) {
        s.start = t;
        s.lastTug = t;
        scatter(el, 0.02);
      }
      s.raf = requestAnimationFrame(function (tt) { frame(el, tt); });
    }

    // A pretend drag: fling one node off in a direction and let the links pull it back.
    function tug(el) {
      var s = el._fdg, nodes = s.g.nodes;
      var n = nodes[Math.floor(Math.random() * nodes.length)];
      var a = Math.atan2(n.y - s.h / 2, n.x - s.w / 2) + (Math.random() - 0.5) * 1.2;
      var p = 9 + Math.random() * 7;
      n.vx += Math.cos(a) * p;
      n.vy += Math.sin(a) * p;
      anime.animate(n.dot, { r: [n.r, n.r * 1.5, n.r], duration: 600, ease: 'out(2)' });
    }

    function grab(el, n, e) {
      var s = el._fdg;
      e.preventDefault();
      s.userTouched = true;
      var box = el.getBoundingClientRect();
      var hx = e.clientX - box.left - n.x, hy = e.clientY - box.top - n.y;
      s.drag = { node: n, px: n.x, py: n.y };
      el.classList.add('fdg-drag');
      anime.animate(n.dot, { r: n.r * 1.35, duration: 200, ease: 'out(2)' });
      function move(ev) {
        var b = el.getBoundingClientRect();
        var x = Math.max(8, Math.min(s.w - 8, ev.clientX - b.left - hx));
        var y = Math.max(8, Math.min(s.h - 8, ev.clientY - b.top - hy));
        n.vx = x - n.x; n.vy = y - n.y;
        n.x = x; n.y = y;
        if (!s.visible || !s.raf) draw(el);
      }
      function up() {
        window.removeEventListener('pointermove', move);
        window.removeEventListener('pointerup', up);
        window.removeEventListener('pointercancel', up);
        s.drag = null;
        el.classList.remove('fdg-drag');
        anime.animate(n.dot, { r: n.r, duration: 400, ease: 'outElastic(1, .5)' });
      }
      window.addEventListener('pointermove', move);
      window.addEventListener('pointerup', up);
      window.addEventListener('pointercancel', up);
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._fdg;
          s.visible = e.isIntersecting;
          if (!s.visible) { cancelAnimationFrame(s.raf); s.raf = 0; return; }
          if (!s.start) s.start = performance.now();
          if (!s.raf) s.raf = requestAnimationFrame(function (t) { frame(el, t); });
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-force-directed-graph]').forEach(function (el) {
        if (el._fdg) return;
        setup(el);
        if (still) {
          // Settle it straight away and show it still (dragging still works).
          scatter(el, 0.3);
          for (var i = 0; i < 400; i++) step(el);
          draw(el);
          return;
        }
        draw(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-force-directed-graph style="height: 320px">
  <span data-id="you">You</span>
  <span data-links="you">Ana</span>
  <span data-links="you">Ben</span>
  <span data-links="ana,ben">Cleo</span>
</div>