Mind Map
Ideas branch out from a central topic. The topic pops in, curved branches sweep out to each main idea, then thinner twigs run out to the smaller ideas, each branch in its own colour. On a narrow screen the topic moves to the top and the ideas list under their branches.
- Launch day
- Design
- Logo
- Website
- Packaging
- Marketing
- Social
- Press
- Product
- Beta
- Pricing
- Team
- Hiring
- Support
- Party
- Design
How to use it
- Paste the snippet just before
</head>. - Write the map as a nested list with one item, the topic, and the main ideas in a
<ul>inside it. Each idea can have its own list of smaller ideas. - Add
data-mind-mapto the outer<ul>.
Options
Add any of these attributes to the same element.
data-color | Colour of the topic. Default #7c3aed. |
data-colors | Comma-separated branch colours, used in turn. Default purple, pink, blue, green, amber, cyan. |
data-color on an idea | That branch’s colour. |
data-duration | Time for the whole map to grow. Default 2400 ms. |
data-loop | Fade out and grow again. |
Text size follows the list’s font-size. Keep labels short: one or two words read best.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Mind Map: ideas branch out from a central topic. The topic pops in, curved
// branches sweep out to each main idea, then thinner twigs run out to the
// smaller ideas, which slide in on their underlines. Each branch gets its
// own colour. Main ideas split left and right of the topic; when the box is
// too narrow for that (on a phone, say), the topic moves to the top and the
// smaller ideas list under their branch instead.
// Usage: a nested list with one item, the topic.
// <ul data-mind-map>
// <li>Launch
// <ul>
// <li>Design <ul><li>Logo</li><li>Site</li></ul></li>
// <li>Marketing <ul><li>Social</li><li>Press</li></ul></li>
// </ul>
// </li>
// </ul>
// Text size follows the element's font-size.
// Optional: data-color (the topic, default #7c3aed), data-colors (comma-
// separated branch colours), data-color on a branch's <li>, data-duration
// (ms for the whole map to grow, default 2400), data-loop (fade out and
// grow again).
var DEFAULTS = { color: '#7c3aed', duration: 2400, hold: 2600, ink: '#1e1b2e',
colors: ['#a855f7', '#ec4899', '#3b82f6', '#10b981', '#f59e0b', '#06b6d4'] };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.mmp{display:flex;align-items:center;justify-content:center;list-style:none;padding:0}' +
'.mmp-svg{display:block;width:100%;height:auto;max-height:100%;overflow:visible}' +
'.mmp-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 parseLi(li) {
var text = '', sub = null;
li.childNodes.forEach(function (c) {
if (c.nodeType === 1 && (c.tagName === 'UL' || c.tagName === 'OL')) sub = sub || c;
else text += c.textContent;
});
return { text: text.replace(/\s+/g, ' ').trim(), color: li.dataset.color, kids: sub ? Array.from(sub.children).map(parseLi) : [] };
}
// A smooth curve that leaves and arrives horizontally (h) or leaves
// vertically and arrives horizontally (v).
function curve(x1, y1, x2, y2, v) {
if (v) return 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + y2 + ' ' + x1 + ',' + y2 + ' ' + x2 + ',' + y2;
var mx = (x1 + x2) / 2;
return 'M' + x1 + ',' + y1 + ' C' + mx + ',' + y1 + ' ' + mx + ',' + y2 + ' ' + x2 + ',' + y2;
}
function render(el) {
var s = el._mmp, d = el.dataset;
var W = el.clientWidth || 320;
var fs = parseFloat(getComputedStyle(el).fontSize) || 14;
var palette = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
s.w = el.clientWidth;
el.textContent = '';
var svg = mk('svg', { class: 'mmp-svg', 'aria-hidden': 'true' }, el);
var root = mk('g', {}, svg);
var links = mk('g', { fill: 'none', 'stroke-linecap': 'round' }, root);
var nodes = mk('g', {}, root);
var probe = mk('text', { 'font-size': fs, 'font-weight': 600 }, svg);
function tw(text, size) {
probe.setAttribute('font-size', size);
probe.textContent = text;
var w = 0;
try { w = probe.getComputedTextLength(); } catch (e) {}
return w || text.length * size * 0.56;
}
var topic = s.tree, branches = topic.kids;
var cfs = fs * 1.15, cw = tw(topic.text, cfs) + fs * 2.2, ch = fs * 2.7;
var bh = fs * 2.1, rowH = fs * 2.15, pad = fs * 0.4;
branches.forEach(function (b, i) {
b.c = b.color || palette[i % palette.length];
b.w = tw(b.text, fs) + fs * 1.6;
b.kids.forEach(function (k) { k.w = tw(k.text, fs * 0.92) + fs * 0.9; });
});
var split = Math.ceil(branches.length / 2);
var sides = [branches.slice(0, split), branches.slice(split)];
function widest(list, key) { return list.reduce(function (m, x) { return Math.max(m, x[key] || 0); }, 0); }
function leafW(list) { return list.reduce(function (m, b) { return Math.max(m, widest(b.kids, 'w')); }, 0); }
var need = Math.max.apply(null, sides.map(function (side) { return widest(side, 'w') + leafW(side); }));
var wide = cw / 2 + fs * 3 + need + fs * 2 + pad <= W / 2;
var cx = W / 2, H;
s.topic = null; s.branches = []; s.leaves = [];
var anim = { links1: [], nodes1: [], links2: [], nodes2: [] };
function pill(x, y, w, h, fill, stroke, text, size, color, pop) {
var g = mk('g', { class: 'mmp-pop' }, nodes);
mk('rect', { x: x - w / 2, y: y - h / 2, width: w, height: h, rx: h / 2, fill: fill, stroke: stroke || 'none', 'stroke-width': 1.6 }, g);
var t = mk('text', { x: x, y: y, 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': size, 'font-weight': pop ? 700 : 600, fill: color }, g);
t.textContent = text;
return g;
}
function leaf(b, k, x0, y, sgn) {
// Twig plus underline in one path; the text sits on the underline.
var g = mk('g', {}, nodes);
var t = mk('text', { x: x0 + sgn * fs * 0.35, y: y, 'text-anchor': sgn > 0 ? 'start' : 'end', 'font-size': fs * 0.92, 'font-weight': 500, fill: DEFAULTS.ink }, g);
t.textContent = k.text;
return { g: g, x: x0, y: y + fs * 0.4 };
}
if (wide) {
var sideH = sides.map(function (side) {
return side.reduce(function (a, b) { return a + Math.max(1, b.kids.length) * rowH; }, 0) + Math.max(0, side.length - 1) * fs * 0.7;
});
H = Math.max(sideH[0], sideH[1], ch) + pad * 2 + fs * 0.6;
var cy = H / 2;
var spare = W / 2 - (cw / 2 + fs * 3 + need + fs * 2 + pad);
var gap1 = fs * 3 + Math.min(spare * 0.45, fs * 5), gap2 = fs * 2 + Math.min(spare * 0.35, fs * 4);
sides.forEach(function (side, si) {
var sgn = si === 0 ? 1 : -1;
var y = cy - sideH[si] / 2;
var bwMax = widest(side, 'w');
side.forEach(function (b) {
var rows = Math.max(1, b.kids.length);
var by = y + (rows * rowH) / 2;
var inner = cx + sgn * (cw / 2 + gap1);
var bx = inner + (sgn * b.w) / 2;
anim.links1.push(mk('path', { d: curve(cx + sgn * cw * 0.42, cy, inner, by), stroke: b.c, 'stroke-width': fs * 0.24 }, links));
anim.nodes1.push(pill(bx, by, b.w, bh, '#fff', b.c, b.text, fs, DEFAULTS.ink));
var lx = inner + sgn * (bwMax + gap2);
b.kids.forEach(function (k, j) {
var ly = y + (j + 0.5) * rowH;
var lf = leaf(b, k, lx, ly, sgn);
anim.links2.push(mk('path', { d: curve(inner + sgn * b.w, by, lx, lf.y) + ' H' + (lx + sgn * k.w), stroke: b.c, 'stroke-width': fs * 0.12, 'stroke-opacity': 0.85 }, links));
anim.nodes2.push(lf.g);
});
y += rows * rowH + fs * 0.7;
});
});
s.topic = pill(cx, cy, cw, ch, d.color || DEFAULTS.color, null, topic.text, cfs, '#fff', true);
} else {
// Narrow: topic on top, a column of branches each side, ideas listed under them.
var top = pad + ch / 2;
var cols = sides.map(function (side, si) {
var sgn = si === 0 ? 1 : -1;
var inner = cx + sgn * fs * 0.8;
var y = top + ch / 2 + fs * 1.2;
side.forEach(function (b) {
var by = y + bh / 2;
anim.links1.push(mk('path', { d: curve(cx + sgn * fs * 0.3, top + ch / 2, inner, by, true), stroke: b.c, 'stroke-width': fs * 0.22 }, links));
anim.nodes1.push(pill(inner + (sgn * b.w) / 2, by, b.w, bh, '#fff', b.c, b.text, fs, DEFAULTS.ink));
y += bh + fs * 0.2;
var lx = inner + sgn * fs * 1.6;
b.kids.forEach(function (k) {
var ly = y + rowH / 2;
var lf = leaf(b, k, lx, ly, sgn);
anim.links2.push(mk('path', { d: curve(inner + sgn * fs * 0.8, by + bh / 2, lx, lf.y, true) + ' H' + (lx + sgn * k.w), stroke: b.c, 'stroke-width': fs * 0.12, 'stroke-opacity': 0.85 }, links));
anim.nodes2.push(lf.g);
y += rowH;
});
y += fs * 0.9;
});
return y;
});
H = Math.max(cols[0], cols[1]) + pad;
s.topic = pill(cx, top, cw, ch, d.color || DEFAULTS.color, null, topic.text, cfs, '#fff', true);
}
svg.removeChild(probe);
H = Math.ceil(H);
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.setAttribute('width', W);
svg.setAttribute('height', H);
s.root = root;
s.anim = anim;
}
function hide(s) {
var a = s.anim;
anime.utils.set(s.root, { opacity: 1 });
anime.utils.set(s.topic, { opacity: 0, scale: 0.4 });
anime.utils.set(a.nodes1, { opacity: 0, scale: 0.5 });
anime.utils.set(a.nodes2, { opacity: 0 });
a.links1.concat(a.links2).forEach(function (p) { anime.utils.set(anime.svg.createDrawable(p), { draw: '0 0' }); });
}
function start(el) {
var s = el._mmp, a = s.anim;
var total = +el.dataset.duration || DEFAULTS.duration;
var k = total / 2400;
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;
tl.add(s.topic, { opacity: [0, 1], scale: [0.4, 1], duration: 600 * k, ease: 'outBack(2)' }, 0);
var t1 = 350 * k, st1 = 130 * k, d1 = 600 * k;
a.links1.forEach(function (p, i) {
tl.add(anime.svg.createDrawable(p), { draw: ['0 0', '0 1'], duration: d1, ease: 'inOut(2)' }, t1 + i * st1);
tl.add(a.nodes1[i], { opacity: [0, 1], scale: [0.5, 1], duration: 450 * k, ease: 'outBack(1.8)' }, t1 + i * st1 + d1 - 120 * k);
});
var t2 = t1 + a.links1.length * st1 + d1, st2 = Math.min(90, 900 / Math.max(1, a.links2.length)) * k, d2 = 520 * k;
a.links2.forEach(function (p, i) {
tl.add(anime.svg.createDrawable(p), { draw: ['0 0', '0 1'], duration: d2, ease: 'out(2)' }, t2 + i * st2);
tl.add(a.nodes2[i], { opacity: [0, 1], duration: 380 * k, ease: 'out(2)' }, t2 + i * st2 + d2 * 0.55);
});
var end = t2 + a.links2.length * st2 + d2;
if (s.loop) tl.add(s.root, { opacity: [1, 0], duration: 500, ease: 'in(2)' }, end + DEFAULTS.hold);
}
function setup(el) {
var first = el.querySelector('li');
if (!first) return;
var s = (el._mmp = { tree: parseLi(first), loop: on(el.dataset.loop), tl: null, visible: false, done: false });
el.classList.add('mmp');
el.setAttribute('role', 'img');
if (!el.getAttribute('aria-label')) {
el.setAttribute('aria-label', 'Mind map of ' + s.tree.text + ': ' + s.tree.kids.map(function (b) {
return b.text + (b.kids.length ? ' (' + b.kids.map(function (k) { return k.text; }).join(', ') + ')' : '');
}).join('; '));
}
render(el);
if (reduced()) {
new ResizeObserver(function () { if (Math.abs(el.clientWidth - s.w) > 1) render(el); }).observe(el);
return;
}
hide(s);
new ResizeObserver(function () {
if (Math.abs(el.clientWidth - s.w) <= 1) return;
if (s.tl) s.tl.pause();
s.tl = null;
render(el);
if (s.done) return;
hide(s);
if (s.visible) start(el);
}).observe(el);
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);
}
function init() {
document.querySelectorAll('[data-mind-map]').forEach(function (el) {
if (el._mmp) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><ul data-mind-map>
<li>Launch
<ul>
<li>Design <ul><li>Logo</li><li>Site</li></ul></li>
<li>Marketing <ul><li>Social</li><li>Press</li></ul></li>
</ul>
</li>
</ul>