← Diagrams & maps

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

How to use it

  1. Paste the snippet just before </head>.
  2. 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.
  3. Add data-mind-map to the outer <ul>.

Options

Add any of these attributes to the same element.

data-colorColour of the topic. Default #7c3aed.
data-colorsComma-separated branch colours, used in turn. Default purple, pink, blue, green, amber, cyan.
data-color on an ideaThat branch’s colour.
data-durationTime for the whole map to grow. Default 2400 ms.
data-loopFade out and grow again.

Text size follows the list’s font-size. Keep labels short: one or two words read best.

1 · Snippet
<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>
2 · Your HTML
<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>