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
- Paste the snippet just before
</head>. - Add
data-quadrant-matrixto a container with one child per item. - Place each item with
data-xanddata-y, from 0 to 100 (0, 0 is bottom left). - Name the axes with
data-x-labelanddata-y-label, and the quadrants withdata-quadrants.
Options
Add any of these attributes to the same element.
data-x / data-y | On an item: where it sits, 0–100. |
data-x-label / data-y-label | Names for the two axes. |
data-quadrants | Four comma-separated names: top left, top right, bottom left, bottom right. |
data-colors | Four comma-separated quadrant colours, same order. Default green, purple, grey, pink. |
data-stagger | Gap between items dropping in. Default 180 ms. |
data-loop | Clear and build again. |
It scales to the width of its container; keep item names to a word or two.
<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><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>