← Charts

Chord Diagram

A chord diagram. Segments sweep round a circle, one per group and sized by its total, then ribbons reach across the middle to link each pair, as wide at each end as the amount that side sends. Point at or tap a segment to pick out its ribbons.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-chord-diagram to an element with a height, with the group names in data-names.
  3. Add data-matrix: one row per group, separated by ;. Row i, column j is how much group i sends to group j.

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours, one per group. Default purple, pink, blue…
data-labelsfalse hides the names round the edge.
data-autoplayPick out each group in turn while nobody is pointing at it.
data-durationTime for the circle to sweep round. Default 1000 ms.
data-loopClear and draw again every few seconds.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Chord Diagram: segments sweep round a circle, one per group, sized by
    // its total. Then ribbons reach across the middle to link each pair of
    // groups, as wide at each end as the amount that side sends. Point at
    // or tap a segment to pick out its ribbons.
    // Usage:
    //   <div data-chord-diagram data-names="Design,Product,Eng"
    //        data-matrix="0,8,5; 6,0,12; 3,9,0" style="height:280px"></div>
    // data-matrix has one row per group, separated by ";": row i, column j is
    // the amount group i sends to group j (the diagonal is ignored).
    // Optional: data-colors="#a855f7,#ec4899,…", data-labels="false" (no
    // names round the edge), data-autoplay (pick out each group in turn while
    // nobody is pointing), data-duration (ms for the circle to sweep round,
    // default 1000), data-loop (clear and draw again every few seconds).
    var DEFAULTS = { duration: 1000, hold: 4200, focus: 1600, pad: 0.05 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var NS = 'http://www.w3.org/2000/svg';
    var TAU = Math.PI * 2;

    var css =
      '.chd{position:relative;font-size:12px}' +
      '.chd svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.chd-arc{cursor:pointer;transition:opacity .3s}' +
      '.chd-rib{stroke:#fff;stroke-width:.75;stroke-opacity:.6;transition:fill-opacity .3s}' +
      '.chd-name{fill:currentColor;font-weight:600;transition:opacity .3s}' +
      '.chd-sum{fill:currentColor;fill-opacity:.55;font-weight:500;font-variant-numeric:tabular-nums}';
    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 clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
    function outQ(x) { x = clamp01(x); return 1 - Math.pow(1 - x, 3); }

    function setup(el) {
      var d = el.dataset;
      var rows = (d.matrix || '').split(/[;|]/).map(function (r) {
        return r.split(/[\s,]+/).filter(function (x) { return x !== ''; }).map(function (x) { return Math.max(0, +x || 0); });
      }).filter(function (r) { return r.length; });
      var n = rows.length;
      var names = (d.names || '').split(',').map(function (s) { return s.trim(); });
      var colors = d.colors ? d.colors.split(',') : PALETTE;
      var m = function (i, j) { return i === j ? 0 : (rows[i][j] || 0); };
      var groups = [], total = 0;
      for (var i = 0; i < n; i++) {
        var s = 0;
        for (var j = 0; j < n; j++) s += m(i, j);
        groups.push({ i: i, name: names[i] || 'Group ' + (i + 1), color: colors[i % colors.length], sum: s });
        total += s;
      }
      // Lay the groups round the circle with a little gap between each.
      var pad = n > 1 ? DEFAULTS.pad : 0, k = (TAU - pad * n) / (total || 1), a = 0;
      groups.forEach(function (g) {
        g.a0 = a; g.a1 = a + g.sum * k;
        var sub = a;
        g.sub = [];
        for (var j = 0; j < n; j++) { g.sub[j] = [sub, sub + m(g.i, j) * k]; sub += m(g.i, j) * k; }
        a = g.a1 + pad;
      });
      var chords = [];
      for (i = 0; i < n; i++) for (j = i + 1; j < n; j++) {
        if (!m(i, j) && !m(j, i)) continue;
        var big = m(i, j) >= m(j, i) ? i : j, other = big === i ? j : i;
        chords.push({ s: big, t: other, src: groups[big].sub[other], dst: groups[other].sub[big] });
      }
      var c = { el: el, groups: groups, chords: chords, sweep: 0, rib: chords.map(function () { return 0; }), focus: -1 };
      el.classList.add('chd');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Chord diagram. ' + chords.map(function (ch) {
        return groups[ch.s].name + ' and ' + groups[ch.t].name + ': ' + m(ch.s, ch.t) + ' one way, ' + m(ch.t, ch.s) + ' the other';
      }).join('. ') + '.');
      el._chd = c;
      layout(c);
      return c;
    }

    function layout(c) {
      var W = c.el.clientWidth, H = c.el.clientHeight, d = c.el.dataset;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var labels = d.labels !== 'false';
      var lw = labels ? Math.max.apply(null, c.groups.map(function (g) { return g.name.length * 7; })) + 12 : 4;
      var R = Math.max(30, Math.min(W / 2 - lw, H / 2 - (labels ? 20 : 4)));
      var ring = Math.max(6, Math.min(14, R * 0.1));
      var r = R - ring - 3;
      c.cx = W / 2; c.cy = H / 2; c.R = R; c.r = r; c.ring = ring;
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      var gr = mk('g', {}, svg);
      c.ribs = c.chords.map(function (ch) {
        return mk('path', { class: 'chd-rib', fill: c.groups[ch.s].color, 'fill-opacity': .62 }, gr);
      });
      c.arcs = c.groups.map(function (g) {
        var p = mk('path', { class: 'chd-arc', fill: g.color }, svg);
        function on() { c.user = Date.now(); setFocus(c, g.i); }
        p.addEventListener('pointerenter', on);
        p.addEventListener('pointerdown', on);
        p.addEventListener('pointerleave', function () { if (c.focus === g.i) setFocus(c, -1); });
        return p;
      });
      c.names = labels ? c.groups.map(function (g) {
        var mid = (g.a0 + g.a1) / 2, sx = Math.sin(mid), cy = -Math.cos(mid);
        var t = mk('text', {
          class: 'chd-name', x: c.cx + (R + 8) * sx, y: c.cy + (R + 8) * cy, dy: '.35em',
          'text-anchor': Math.abs(sx) < 0.3 ? 'middle' : sx > 0 ? 'start' : 'end'
        }, svg);
        if (Math.abs(sx) < 0.3) t.setAttribute('dy', cy < 0 ? '-.1em' : '.85em');
        t.textContent = g.name;
        return t;
      }) : [];
      svg.addEventListener('pointerleave', function () { setFocus(c, -1); });
      c.el.innerHTML = '';
      c.el.appendChild(svg);
      render(c);
      setFocus(c, c.focus);
    }

    function pt(c, a, r) { return (c.cx + r * Math.sin(a)).toFixed(2) + ' ' + (c.cy - r * Math.cos(a)).toFixed(2); }
    function arcTo(c, a0, a1, r, sweep) {
      return 'A' + r + ' ' + r + ' 0 ' + (Math.abs(a1 - a0) > Math.PI ? 1 : 0) + ' ' + sweep + ' ' + pt(c, a1, r);
    }

    function render(c) {
      if (!c.arcs) return;
      var sw = c.sweep * TAU;
      c.groups.forEach(function (g, i) {
        var a1 = Math.min(g.a1, sw), p = c.arcs[i];
        if (a1 <= g.a0) { p.setAttribute('d', ''); }
        else p.setAttribute('d', 'M' + pt(c, g.a0, c.R) + arcTo(c, g.a0, a1, c.R, 1) + 'L' + pt(c, a1, c.R - c.ring) +
          arcTo(c, a1, g.a0, c.R - c.ring, 0) + 'Z');
        if (c.names[i]) c.names[i].style.opacity = clamp01((sw - (g.a0 + g.a1) / 2) * 3) * (c.focus < 0 || c.focus === i ? 1 : 0.45);
      });
      c.chords.forEach(function (ch, i) {
        var k = outQ(c.rib[i]), p = c.ribs[i];
        if (k <= 0) { p.setAttribute('d', ''); return; }
        // The far end travels across from the near end and widens into place.
        var mid = (ch.src[0] + ch.src[1]) / 2;
        var b0 = mid + (ch.dst[0] - mid) * k, b1 = mid + (ch.dst[1] - mid) * k;
        var c0 = c.cx + ' ' + c.cy;
        p.setAttribute('d', 'M' + pt(c, ch.src[0], c.r) + arcTo(c, ch.src[0], ch.src[1], c.r, 1) +
          'Q' + c0 + ' ' + pt(c, b0, c.r) + arcTo(c, b0, b1, c.r, 1) + 'Q' + c0 + ' ' + pt(c, ch.src[0], c.r) + 'Z');
      });
    }

    function setFocus(c, i) {
      c.focus = i;
      if (!c.ribs) return;
      c.chords.forEach(function (ch, j) {
        var on = i < 0 || ch.s === i || ch.t === i;
        c.ribs[j].setAttribute('fill-opacity', on ? (i < 0 ? 0.62 : 0.85) : 0.08);
      });
      c.arcs.forEach(function (a, j) { a.style.opacity = i < 0 || i === j ? 1 : 0.35; });
      render(c);
    }

    function autoplay(c) {
      if (c.auto) return;
      var step = 0;
      c.auto = setInterval(function () {
        if (!c.visible || !c.ready) return;
        if (c.user && Date.now() - c.user < 4000) return;
        // Every other beat, show everything again.
        step++;
        setFocus(c, step % 2 ? Math.floor(step / 2) % c.groups.length : -1);
      }, DEFAULTS.focus);
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration;
      c.ready = false;
      c.sweep = 0;
      c.rib = c.rib.map(function () { return 0; });
      setFocus(c, -1);
      var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
      tl.add(c, { sweep: 1, duration: dur, ease: 'inOut(2)' }, 0);
      var each = 900, gap = Math.min(140, 900 / Math.max(1, c.chords.length));
      c.chords.forEach(function (ch, i) {
        var s = { k: 0 };
        tl.add(s, { k: 1, duration: each, ease: 'inOut(2)', onUpdate: function () { c.rib[i] = s.k; } }, dur * 0.8 + i * gap);
      });
      tl.then(function () {
        c.ready = true;
        if (d.loop !== undefined && d.loop !== 'false') {
          setTimeout(function () {
            c.ready = false;
            setFocus(c, -1);
            var o = { k: 1 };
            anime.animate(o, {
              k: 0, duration: 600, ease: 'in(2)',
              onUpdate: function () { c.rib = c.rib.map(function () { return o.k; }); c.sweep = o.k; render(c); },
              onComplete: function () { setTimeout(function () { play(c); }, 250); }
            });
          }, DEFAULTS.hold + (d.autoplay !== undefined ? DEFAULTS.focus * c.groups.length : 0));
        }
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._chd;
          c.visible = e.isIntersecting;
          if (!e.isIntersecting || c.played) return;
          c.played = true;
          play(c);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._chd;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-chord-diagram]').forEach(function (el) {
        if (el._chd) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) {
          c.sweep = 1; c.rib = c.rib.map(function () { return 1; }); c.ready = true; c.played = true;
          render(c);
          return;
        }
        if (el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false') autoplay(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-chord-diagram data-names="Design,Product,Eng"
     data-matrix="0,8,5; 6,0,12; 3,9,0" style="height: 280px"></div>