← Diagrams & maps

Quadrant Matrix

A two-by-two matrix that draws itself: the axes draw out with arrowheads, the dividers split the square into four, each quadrant tints in with its name, and then the items drop into place one by one, coloured by the quadrant they land in.

Fix sign-up Dark mode New app Search Typos New icons Rewrite

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-quadrant-matrix to a container with one child per item.
  3. Place each item with data-x and data-y, from 0 to 100 (0, 0 is bottom left).
  4. Name the axes with data-x-label and data-y-label, and the quadrants with data-quadrants.

Options

Add any of these attributes to the same element.

data-x / data-yOn an item: where it sits, 0–100.
data-x-label / data-y-labelNames for the two axes.
data-quadrantsFour comma-separated names: top left, top right, bottom left, bottom right.
data-colorsFour comma-separated quadrant colours, same order. Default green, purple, grey, pink.
data-staggerGap between items dropping in. Default 180 ms.
data-loopClear and build again.

It scales to the width of its container; keep item names to a word or two.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Quadrant Matrix: a two-by-two matrix that draws itself. The two axes
    // draw out with arrowheads, the dividers split the square into four, each
    // quadrant tints in with its name, and then the items drop into place one
    // by one with a little bounce, coloured by the quadrant they land in.
    // Usage:
    //   <div data-quadrant-matrix data-x-label="Effort" data-y-label="Impact"
    //        data-quadrants="Quick wins,Big bets,Fill-ins,Time sinks">
    //     <span data-x="20" data-y="80">Fix sign-up</span>
    //     <span data-x="75" data-y="70">New app</span> …
    //   </div>
    // data-x and data-y place each item, from 0 to 100 (0,0 is bottom left).
    // data-quadrants names the quadrants: top left, top right, bottom left,
    // bottom right.
    // Optional: data-x-label, data-y-label (axis names), data-colors (four
    // comma-separated quadrant colours, same order), data-stagger (ms between
    // items, default 180), data-loop (clear and build again).
    var DEFAULTS = { colors: ['#10b981', '#a855f7', '#94a3b8', '#ec4899'], stagger: 180, hold: 2600, ink: '#1e1b2e', axis: '#6b5b86' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.qdm{display:flex;align-items:center;justify-content:center}' +
      '.qdm-svg{display:block;width:100%;height:auto;max-height:100%;overflow:visible}' +
      '.qdm-pop{transform-box:fill-box;transform-origin:50% 50%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    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 build(el) {
      var d = el.dataset;
      var items = Array.from(el.children).map(function (c) {
        return { text: c.textContent.trim(), x: Math.max(0, Math.min(100, +c.dataset.x || 0)), y: Math.max(0, Math.min(100, +c.dataset.y || 0)) };
      });
      var names = (d.quadrants || '').split(',').map(function (q) { return q.trim(); });
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var W = 340, H = 290, x0 = 26, x1 = 332, y0 = 8, y1 = 266;
      var mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) {
        el.setAttribute('aria-label', 'Two-by-two matrix' + (d.xLabel ? ' of ' + d.xLabel + ' against ' + (d.yLabel || '') : '') + ': ' +
          items.map(function (it) { return it.text; }).join(', '));
      }
      el.textContent = '';
      el.classList.add('qdm');
      var svg = mk('svg', { class: 'qdm-svg', viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' }, el);

      // Quadrants: tint and name, top left, top right, bottom left, bottom right.
      var quads = [[x0, y0], [mx, y0], [x0, my], [mx, my]].map(function (p, i) {
        var g = mk('g', {}, svg);
        mk('rect', { x: p[0] + 1, y: p[1], width: mx - x0 - 1, height: my - y0 - 1, fill: colors[i], 'fill-opacity': 0.1 }, g);
        if (names[i]) {
          var t = mk('text', { x: p[0] + 10, y: p[1] + 18, 'font-size': 11.5, 'font-weight': 700, fill: colors[i], 'letter-spacing': '0.04em' }, g);
          t.textContent = names[i].toUpperCase();
        }
        return g;
      });

      var lines = mk('g', { fill: 'none', 'stroke-linecap': 'round' }, svg);
      var axes = [
        mk('path', { d: 'M' + x0 + ',' + y1 + ' H' + (x1 - 2), stroke: DEFAULTS.axis, 'stroke-width': 2 }, lines),
        mk('path', { d: 'M' + x0 + ',' + y1 + ' V' + (y0 + 2), stroke: DEFAULTS.axis, 'stroke-width': 2 }, lines)
      ];
      var heads = [
        mk('polygon', { class: 'qdm-pop', points: [x1 + 4, y1, x1 - 6, y1 - 5, x1 - 6, y1 + 5].join(' '), fill: DEFAULTS.axis }, svg),
        mk('polygon', { class: 'qdm-pop', points: [x0, y0 - 4, x0 - 5, y0 + 6, x0 + 5, y0 + 6].join(' '), fill: DEFAULTS.axis }, svg)
      ];
      // Dividers grow out from the middle both ways.
      var dividers = [
        mk('path', { d: 'M' + mx + ',' + my + ' V' + y0, stroke: DEFAULTS.axis, 'stroke-width': 1.2, 'stroke-opacity': 0.45 }, lines),
        mk('path', { d: 'M' + mx + ',' + my + ' V' + y1, stroke: DEFAULTS.axis, 'stroke-width': 1.2, 'stroke-opacity': 0.45 }, lines),
        mk('path', { d: 'M' + mx + ',' + my + ' H' + x0, stroke: DEFAULTS.axis, 'stroke-width': 1.2, 'stroke-opacity': 0.45 }, lines),
        mk('path', { d: 'M' + mx + ',' + my + ' H' + x1, stroke: DEFAULTS.axis, 'stroke-width': 1.2, 'stroke-opacity': 0.45 }, lines)
      ];
      var axisLabels = [];
      if (d.xLabel) {
        var xl = mk('text', { x: mx, y: y1 + 18, 'text-anchor': 'middle', 'font-size': 12.5, 'font-weight': 700, fill: DEFAULTS.axis }, svg);
        xl.textContent = d.xLabel + ' →';
        axisLabels.push(xl);
      }
      if (d.yLabel) {
        var yl = mk('text', { x: 0, y: 0, transform: 'translate(' + (x0 - 12) + ',' + my + ') rotate(-90)', 'text-anchor': 'middle', 'font-size': 12.5, 'font-weight': 700, fill: DEFAULTS.axis }, svg);
        yl.textContent = d.yLabel + ' →';
        axisLabels.push(yl);
      }

      var dots = items.map(function (it) {
        var px = x0 + 12 + ((x1 - x0 - 24) * it.x) / 100, py = y1 - 12 - ((y1 - y0 - 24) * it.y) / 100;
        var q = (it.y >= 50 ? 0 : 2) + (it.x >= 50 ? 1 : 0);
        var g = mk('g', {}, svg);
        mk('circle', { cx: px, cy: py, r: 6.5, fill: colors[q], stroke: '#fff', 'stroke-width': 2 }, g);
        var right = it.x < 68;
        var t = mk('text', { x: px + (right ? 11 : -11), y: py, 'text-anchor': right ? 'start' : 'end', 'dominant-baseline': 'central', 'font-size': 12, 'font-weight': 600, fill: DEFAULTS.ink, stroke: '#fff', 'stroke-width': 3, 'stroke-opacity': 0.7, 'paint-order': 'stroke', 'stroke-linejoin': 'round' }, g);
        t.textContent = it.text;
        return g;
      });
      return { quads: quads, axes: axes, heads: heads, dividers: dividers, axisLabels: axisLabels, dots: dots, loop: on(d.loop), done: false, visible: false };
    }

    function hide(s) {
      s.axes.concat(s.dividers).forEach(function (p) { anime.utils.set(anime.svg.createDrawable(p), { draw: '0 0' }); });
      anime.utils.set(s.heads, { opacity: 0, scale: 0 });
      anime.utils.set(s.quads.concat(s.axisLabels), { opacity: 0 });
      anime.utils.set(s.dots, { opacity: 0, y: -36 });
    }

    function start(el) {
      var s = el._qdm;
      var stagger = +el.dataset.stagger || DEFAULTS.stagger;
      hide(s);
      var tl = anime.createTimeline({
        onComplete: function () {
          if (s.tl !== tl) return;
          if (s.loop) start(el); else s.done = true;
        }
      });
      s.tl = tl;
      s.axes.forEach(function (a) { tl.add(anime.svg.createDrawable(a), { draw: ['0 0', '0 1'], duration: 700, ease: 'inOut(2)' }, 0); });
      tl.add(s.heads, { opacity: [0, 1], scale: [0, 1], duration: 300, ease: 'outBack(2)' }, 620);
      if (s.axisLabels.length) tl.add(s.axisLabels, { opacity: [0, 1], duration: 400 }, 700);
      s.dividers.forEach(function (dv) { tl.add(anime.svg.createDrawable(dv), { draw: ['0 0', '0 1'], duration: 500, ease: 'out(2)' }, 700); });
      s.quads.forEach(function (q, i) { tl.add(q, { opacity: [0, 1], duration: 450, ease: 'out(2)' }, 1000 + i * 90); });
      var t = 1500;
      s.dots.forEach(function (dot, i) {
        tl.add(dot, { y: [-36, 0], duration: 650, ease: 'outBounce' }, t + i * stagger);
        tl.add(dot, { opacity: [0, 1], duration: 200 }, t + i * stagger);
      });
      var end = t + (s.dots.length - 1) * stagger + 650;
      if (s.loop) {
        var back = end + DEFAULTS.hold;
        tl.add(s.dots, { opacity: 0, y: -16, duration: 350, delay: anime.stagger(50), ease: 'in(2)' }, back);
        tl.add(s.quads.concat(s.axisLabels, s.heads), { opacity: 0, duration: 400 }, back + 300);
        s.axes.concat(s.dividers).forEach(function (p) { tl.add(anime.svg.createDrawable(p), { draw: '1 1', duration: 450, ease: 'in(2)' }, back + 350); });
      }
    }

    function init() {
      var still = reduced();
      document.querySelectorAll('[data-quadrant-matrix]').forEach(function (el) {
        if (el._qdm) return;
        var s = (el._qdm = build(el));
        if (still) return;
        hide(s);
        new IntersectionObserver(function (entries) {
          s.visible = entries[0].isIntersecting;
          if (s.visible) { if (!s.tl) start(el); else if (!s.done) s.tl.resume(); }
          else if (s.tl && !s.done) s.tl.pause();
        }, { threshold: 0.2 }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-quadrant-matrix data-x-label="Effort" data-y-label="Impact"
     data-quadrants="Quick wins,Big bets,Fill-ins,Time sinks"
     style="max-width: 420px">
  <span data-x="20" data-y="80">Fix sign-up</span>
  <span data-x="75" data-y="85">New app</span>
  <span data-x="25" data-y="20">Typos</span>
  <span data-x="85" data-y="25">Rewrite</span>
</div>