← Diagrams & maps

Org Chart Grow

An org chart that branches out from the top: the top box pops in, elbow lines grow down to the next level, and each box pops in as its line arrives. On a narrow screen the bottom level of each branch stacks into a column so the chart still fits.

  • Chief Executive
    • Design
      • Brand
      • Product
    • Engineering
      • Web
      • Apps
      • Data
    • Sales
      • Europe
      • Americas

How to use it

  1. Paste the snippet just before </head>.
  2. Write the chart as a nested list: one <li> at the top, with a <ul> of its reports inside, and so on.
  3. Add data-org-chart-grow to the outer <ul>.

Options

Add any of these attributes to the same element.

data-colorColour of the boxes and lines. Default #a855f7.
data-levelTime to grow each level. Default 700 ms.
data-loopFade out and grow again.

Text size follows the list’s font-size. Long names shrink to fit their box.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Org Chart Grow: an org chart that branches out from the top. The top box
    // pops in, elbow lines grow down to the next level, those boxes pop in, and
    // so on down the tree. Write it as a nested list. When the chart is too
    // wide for its box (on a phone, say), the bottom level of each branch
    // stacks into a column so it still fits.
    // Usage:
    //   <ul data-org-chart-grow>
    //     <li>CEO
    //       <ul>
    //         <li>Design <ul><li>Brand</li><li>Product</li></ul></li>
    //         <li>Engineering <ul><li>Web</li><li>Apps</li></ul></li>
    //       </ul>
    //     </li>
    //   </ul>
    // Text size follows the element's font-size.
    // Optional: data-color (default #a855f7), data-level (ms per level, default
    // 700), data-loop (fade out and grow again).
    var DEFAULTS = { color: '#a855f7', level: 700, hold: 2600, ink: '#1e1b2e' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.ocg{display:flex;align-items:center;justify-content:center;list-style:none;padding:0}' +
      '.ocg-svg{display:block;width:100%;height:auto;max-height:100%;overflow:visible}' +
      '.ocg-node{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;
    }

    // Centred text, shrunk to fit maxW.
    function label(parent, text, x, y, maxW, fs, fill) {
      var t = mk('text', { x: x, y: y, 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': fs, fill: fill, 'font-weight': 600 }, parent);
      t.textContent = text;
      var w = 0;
      try { w = t.getComputedTextLength(); } catch (e) {}
      w = w || text.length * fs * 0.56;
      if (w > maxW) t.setAttribute('font-size', Math.max(fs * 0.62, (fs * maxW) / w));
    }

    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(), kids: sub ? Array.from(sub.children).map(parseLi) : [] };
    }

    function slots(n, compact) {
      n.stack = false;
      if (!n.kids.length) return (n.slots = 1);
      if (compact && n.kids.every(function (k) { return !k.kids.length; })) { n.stack = true; return (n.slots = 1); }
      return (n.slots = n.kids.reduce(function (a, k) { return a + slots(k, compact); }, 0));
    }

    function render(el) {
      var s = el._ocg, d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var W = el.clientWidth || 320;
      var fs = parseFloat(getComputedStyle(el).fontSize) || 14;
      s.w = el.clientWidth;
      var tree = s.tree;
      var total = slots(tree, false);
      if (W / total < fs * 6.4) total = slots(tree, true);
      var slotW = W / total;
      var h = fs * 2.5, w = Math.min(slotW - fs * 0.8, fs * 9.5), levelH = fs * 5.2;
      var indent = fs * 1.4, stackGap = fs * 0.7;

      el.textContent = '';
      var svg = mk('svg', { class: 'ocg-svg', 'aria-hidden': 'true' }, el);
      var root = mk('g', {}, svg);
      var lines = mk('g', { fill: 'none', stroke: color, 'stroke-width': 1.6, 'stroke-opacity': 0.7, 'stroke-linejoin': 'round' }, root);
      var boxes = mk('g', {}, root);
      var maxY = 0;
      s.nodes = [];
      s.links = [];

      // Place each node, then give it a box, and a link to its parent.
      function place(n, x0, y, depth, nw) {
        n.x = x0 + (n.slots * slotW) / 2;
        n.y = y;
        // Managers may be wider than one slot when they have room.
        nw = Math.max(nw, Math.min(n.slots * slotW - fs * 0.8, fs * 11));
        draw(n, depth, nw);
        if (n.stack) {
          n.kids.forEach(function (k, j) {
            k.x = n.x + indent / 2;
            k.y = n.y + (j + 1) * (h + stackGap);
            draw(k, depth + 1, nw - indent);
            var rail = n.x - nw / 2 + indent / 2;
            link(n, k, 'M' + rail + ',' + (n.y + h / 2) + ' V' + k.y + ' H' + (k.x - (nw - indent) / 2), j);
          });
        } else {
          var x = x0;
          n.kids.forEach(function (k, j) {
            var kx = x + (k.slots * slotW) / 2, ky = y + levelH, mid = y + h / 2 + (levelH - h) / 2;
            link(n, k, 'M' + n.x + ',' + (y + h / 2) + ' V' + mid + ' H' + kx + ' V' + (ky - h / 2), 0);
            place(k, x, ky, depth + 1, w);
            x += k.slots * slotW;
          });
        }
      }
      function draw(n, depth, nw) {
        var g = mk('g', { class: 'ocg-node' }, boxes);
        var top = depth === 0;
        mk('rect', { x: n.x - nw / 2, y: n.y - h / 2, width: nw, height: h, rx: fs * 0.55, fill: top ? color : '#fff', stroke: color, 'stroke-width': top ? 0 : 1.6, 'stroke-opacity': depth > 1 ? 0.55 : 1 }, g);
        label(g, n.text, n.x, n.y, nw - fs * 0.9, fs, top ? '#fff' : DEFAULTS.ink);
        maxY = Math.max(maxY, n.y + h / 2);
        n.g = g;
        s.nodes.push(n);
      }
      function link(p, k, path, j) {
        s.links.push({ from: p, to: k, path: mk('path', { d: path }, lines), order: j });
      }

      place(tree, 0, h / 2 + 2, 0, w);
      var H = Math.ceil(maxY + 4);
      svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
      svg.setAttribute('width', W);
      svg.setAttribute('height', H);
      s.root = root;
    }

    function hide(s) {
      anime.utils.set(s.root, { opacity: 1 });
      s.nodes.forEach(function (n) { anime.utils.set(n.g, { opacity: 0, scale: 0.5 }); });
      s.links.forEach(function (l) { anime.utils.set(anime.svg.createDrawable(l.path), { draw: '0 0' }); });
    }

    function start(el) {
      var s = el._ocg;
      var level = +el.dataset.level || DEFAULTS.level;
      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;
      var end = 0;
      // Each box pops at its own time; its links grow after it, and each child
      // pops as its link arrives.
      function grow(n, t) {
        tl.add(n.g, { opacity: [0, 1], scale: [0.5, 1], duration: 450, ease: 'outBack(1.7)' }, t);
        end = Math.max(end, t + 450);
        s.links.forEach(function (l) {
          if (l.from !== n) return;
          var dur = level * 0.55 + l.order * 110;
          var t0 = t + level * 0.3;
          tl.add(anime.svg.createDrawable(l.path), { draw: ['0 0', '0 1'], duration: dur, ease: 'inOut(2)' }, t0);
          grow(l.to, t0 + dur - 90);
        });
      }
      grow(s.tree, 0);
      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._ocg = { tree: parseLi(first), loop: on(el.dataset.loop), tl: null, visible: false, done: false });
      el.classList.add('ocg');
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) {
        var names = [];
        (function walk(n) { names.push(n.text); n.kids.forEach(walk); })(s.tree);
        el.setAttribute('aria-label', 'Org chart: ' + names.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-org-chart-grow]').forEach(function (el) {
        if (el._ocg) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<ul data-org-chart-grow>
  <li>CEO
    <ul>
      <li>Design <ul><li>Brand</li><li>Product</li></ul></li>
      <li>Engineering <ul><li>Web</li><li>Apps</li></ul></li>
    </ul>
  </li>
</ul>