← Diagrams & maps

Decision Tree

A decision tree grows down from its first question, branch by branch, with Yes and No on its forks. Then one route lights up question by question, the rest of the tree dims, and the answer it lands on pulses. Loop it and it lights a different route each time.

  • Is it raining?
    • Is it windy?
      • Raincoat
      • Umbrella
    • Is it sunny?
      • Sun hat
      • Jumper

How to use it

  1. Paste the snippet just before </head>.
  2. Write the tree as a nested list: the first question, with a <ul> of what follows it, down to the answers.
  3. Label each fork with data-edge (such as Yes or No) on the item it leads to.
  4. Add data-decision-tree to the outer <ul>, and data-chosen to the answer whose route should light up.

Options

Add any of these attributes to the same element.

data-chosenOn an answer: light the route to it. Default: a random answer.
data-edgeOn an item: the label on the branch leading to it.
data-colorColour of the lit route. Default #a855f7.
data-levelTime to grow each level. Default 600 ms.
data-loopLight a different route every few seconds.

Text size follows the list’s font-size. Short questions and answers fit best on phones.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Decision Tree: a decision tree grows down from its first question, its
    // branches drawing out level by level with their Yes/No labels. Then one
    // route through it lights up, question by question, everything else dims,
    // and the answer it lands on pulses. Looping, it lights a different route
    // each time. Write it as a nested list.
    // Usage:
    //   <ul data-decision-tree>
    //     <li>Raining?
    //       <ul>
    //         <li data-edge="Yes">Windy?
    //           <ul><li data-edge="Yes">Raincoat</li><li data-edge="No" data-chosen>Umbrella</li></ul>
    //         </li>
    //         <li data-edge="No">Sunglasses</li>
    //       </ul>
    //     </li>
    //   </ul>
    // data-edge on an item labels the branch leading to it. data-chosen on an
    // answer picks the route to light (otherwise it picks one at random).
    // Text size follows the element's font-size.
    // Optional: data-color (the lit route, default #a855f7), data-level (ms per
    // level as it grows, default 600), data-loop (light a new route every few
    // seconds).
    var DEFAULTS = { color: '#a855f7', level: 600, hold: 2200, ink: '#1e1b2e', line: '#c9b8e3' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.dct{display:flex;align-items:center;justify-content:center;list-style:none;padding:0}' +
      '.dct-svg{display:block;width:100%;height:auto;max-height:100%;overflow:visible}' +
      '.dct-pop{transform-box:fill-box;transform-origin:50% 50%}' +
      '.dct-edge-label{paint-order:stroke;stroke-linejoin:round}';
    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 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));
      return t;
    }

    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(), edge: li.dataset.edge || '', chosen: li.dataset.chosen !== undefined,
        kids: sub ? Array.from(sub.children).map(parseLi) : []
      };
    }

    function leaves(n) { return n.kids.length ? n.kids.reduce(function (a, k) { return a + leaves(k); }, 0) : 1; }

    function render(el) {
      var s = el._dct, 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 slotW = W / leaves(s.tree);
      var h = fs * 2.5, levelH = fs * 5.6;
      var w = Math.min(slotW - fs * 0.7, fs * 9);

      el.textContent = '';
      var svg = mk('svg', { class: 'dct-svg', 'aria-hidden': 'true' }, el);
      var root = mk('g', {}, svg);
      var lines = mk('g', { fill: 'none', 'stroke-linecap': 'round' }, root);
      var marks = mk('g', {}, root);
      var boxes = mk('g', {}, root);
      var maxY = 0;
      s.nodes = [];

      function place(n, x0, y, parent) {
        var span = leaves(n) * slotW;
        n.x = x0 + span / 2;
        n.y = y;
        n.parent = parent;
        var nw = n.kids.length ? Math.max(w, Math.min(span - fs * 0.8, fs * 10)) : w;
        var leaf = !n.kids.length;
        n.g = mk('g', { class: 'dct-pop' }, boxes);
        var base = mk('g', {}, n.g);
        mk('rect', { x: n.x - nw / 2, y: y - h / 2, width: nw, height: h, rx: leaf ? h / 2 : fs * 0.5, fill: '#fff', stroke: DEFAULTS.line, 'stroke-width': 1.6 }, base);
        label(base, n.text, n.x, y, nw - fs * 0.9, fs, DEFAULTS.ink);
        // The lit look, faded in over the top when the route reaches it.
        n.lit = mk('g', {}, n.g);
        mk('rect', { x: n.x - nw / 2, y: y - h / 2, width: nw, height: h, rx: leaf ? h / 2 : fs * 0.5, fill: leaf ? color : '#fff', stroke: color, 'stroke-width': 2.2 }, n.lit);
        label(n.lit, n.text, n.x, y, nw - fs * 0.9, fs, leaf ? '#fff' : DEFAULTS.ink);
        if (parent) {
          var x1 = parent.x, y1 = parent.y + h / 2, y2 = y - h / 2;
          var dPath = 'M' + x1 + ',' + y1 + ' L' + n.x + ',' + y2;
          n.line = mk('path', { d: dPath, stroke: DEFAULTS.line, 'stroke-width': 1.8 }, lines);
          n.glow = mk('path', { d: dPath, stroke: color, 'stroke-width': 3.2 }, lines);
          if (n.edge) {
            var mx = (x1 + n.x) / 2, my = (y1 + y2) / 2;
            n.mark = mk('g', {}, marks);
            var t = mk('text', { class: 'dct-edge-label', x: mx, y: my, 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': fs * 0.82, 'font-weight': 700, fill: '#8b7aa8', stroke: '#fff', 'stroke-width': fs * 0.35 }, n.mark);
            t.textContent = n.edge;
            n.markLit = mk('text', { class: 'dct-edge-label', x: mx, y: my, 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': fs * 0.82, 'font-weight': 700, fill: color, stroke: '#fff', 'stroke-width': fs * 0.35 }, n.mark);
            n.markLit.textContent = n.edge;
          }
        }
        maxY = Math.max(maxY, y + h / 2);
        s.nodes.push(n);
        var x = x0;
        n.kids.forEach(function (k) { place(k, x, y + levelH, n); x += leaves(k) * slotW; });
      }
      place(s.tree, 0, h / 2 + 2, null);
      var H = Math.ceil(maxY + 4);
      svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
      svg.setAttribute('width', W);
      svg.setAttribute('height', H);
      s.root = root;
      s.grown = false;
    }

    // Everything that dims when a route lights, and the route's own parts.
    function parts(n) { return [n.g, n.line, n.mark].filter(Boolean); }
    function glows(s) { return s.nodes.filter(function (n) { return n.glow; }).map(function (n) { return n.glow; }); }

    function unlit(s) {
      anime.utils.set(s.nodes.map(function (n) { return n.lit; }), { opacity: 0 });
      anime.utils.set(s.nodes.map(function (n) { return n.markLit; }).filter(Boolean), { opacity: 0 });
      anime.utils.set(glows(s), { opacity: 1 });
      glows(s).forEach(function (g) { anime.utils.set(anime.svg.createDrawable(g), { draw: '0 0' }); });
      s.nodes.forEach(function (n) { anime.utils.set(parts(n), { opacity: 1 }); });
    }

    function hide(s) {
      unlit(s);
      anime.utils.set(s.nodes.map(function (n) { return n.g; }), { opacity: 0, scale: 0.5 });
      s.nodes.forEach(function (n) {
        if (n.line) anime.utils.set(anime.svg.createDrawable(n.line), { draw: '0 0' });
        if (n.mark) anime.utils.set(n.mark, { opacity: 0 });
      });
    }

    function pickRoute(s) {
      var ends = s.nodes.filter(function (n) { return !n.kids.length; });
      var end = null;
      if (!s.picked) end = ends.filter(function (n) { return n.chosen; })[0];
      if (!end) {
        var choices = ends.filter(function (n) { return n !== s.last; });
        end = choices[Math.floor(Math.random() * choices.length)] || ends[0];
      }
      s.picked = true;
      s.last = end;
      var route = [];
      for (var n = end; n; n = n.parent) route.unshift(n);
      return route;
    }

    function start(el) {
      var s = el._dct;
      var level = +el.dataset.level || DEFAULTS.level;
      var tl = anime.createTimeline({
        onComplete: function () {
          if (s.tl !== tl) return;
          if (s.loop) start(el); else s.done = true;
        }
      });
      s.tl = tl;
      var t = 0;
      if (!s.grown) {
        hide(s);
        s.grown = true;
        // Grow level by level: box, then its branches and their labels.
        var depth = function (n) { return n.parent ? depth(n.parent) + 1 : 0; };
        s.nodes.forEach(function (n) {
          var dep = depth(n);
          var at = dep * level;
          if (n.line) {
            tl.add(anime.svg.createDrawable(n.line), { draw: ['0 0', '0 1'], duration: level * 0.6, ease: 'inOut(2)' }, at - level * 0.55);
            if (n.mark) tl.add(n.mark, { opacity: [0, 1], duration: 250 }, at - level * 0.3);
          }
          tl.add(n.g, { opacity: [0, 1], scale: [0.5, 1], duration: 420, ease: 'outBack(1.8)' }, at);
          t = Math.max(t, at + 420);
        });
        t += 400;
      } else {
        unlit(s);
        t = 500;
      }

      // Light the route: dim the rest, then trace it down from the top.
      var route = pickRoute(s);
      var others = s.nodes.filter(function (n) { return route.indexOf(n) < 0; });
      var dimmed = [];
      others.forEach(function (n) { dimmed = dimmed.concat(parts(n)); });
      if (dimmed.length) tl.add(dimmed, { opacity: 0.35, duration: 400, ease: 'out(2)' }, t);
      var step = level * 0.75;
      route.forEach(function (n, i) {
        var at = t + i * step;
        if (n.glow) {
          tl.add(anime.svg.createDrawable(n.glow), { draw: ['0 0', '0 1'], duration: step * 0.7, ease: 'inOut(2)' }, at - step * 0.7);
          if (n.markLit) tl.add(n.markLit, { opacity: [0, 1], duration: 200 }, at - step * 0.45);
        }
        tl.add(n.lit, { opacity: [0, 1], duration: 260, ease: 'out(2)' }, at);
      });
      var last = route[route.length - 1];
      var end = t + (route.length - 1) * step;
      tl.add(last.g, { scale: [1, 1.14, 1], duration: 600, ease: 'inOut(2)' }, end + 150);
      end += 750;
      if (s.loop) {
        var back = end + DEFAULTS.hold;
        var litBits = route.map(function (n) { return n.lit; }).concat(route.map(function (n) { return n.markLit; }).filter(Boolean));
        tl.add(litBits.concat(route.map(function (n) { return n.glow; }).filter(Boolean)), { opacity: 0, duration: 400, ease: 'in(2)' }, back);
        if (dimmed.length) tl.add(dimmed, { opacity: 1, duration: 400 }, back);
      }
    }

    function setup(el) {
      var first = el.querySelector('li');
      if (!first) return;
      var s = (el._dct = { tree: parseLi(first), loop: on(el.dataset.loop), tl: null, visible: false, done: false });
      el.classList.add('dct');
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) {
        var desc = function (n) {
          return n.text + (n.kids.length ? ' (' + n.kids.map(function (k) { return (k.edge ? k.edge + ': ' : '') + desc(k); }).join('; ') + ')' : '');
        };
        el.setAttribute('aria-label', 'Decision tree: ' + desc(s.tree));
      }
      render(el);
      if (reduced()) {
        var still = function () {
          render(el);
          var route = pickRoute(s);
          unlit(s);
          s.nodes.forEach(function (n) {
            var lit = route.indexOf(n) >= 0;
            if (lit) { anime.utils.set([n.lit, n.markLit, n.glow].filter(Boolean), { opacity: 1 }); if (n.glow) anime.utils.set(anime.svg.createDrawable(n.glow), { draw: '0 1' }); }
            else anime.utils.set(parts(n), { opacity: 0.35 });
          });
        };
        still();
        new ResizeObserver(function () { if (Math.abs(el.clientWidth - s.w) > 1) { s.picked = false; still(); } }).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);
        hide(s);
        s.done = false;
        s.picked = false;
        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-decision-tree]').forEach(function (el) {
        if (el._dct) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<ul data-decision-tree>
  <li>Raining?
    <ul>
      <li data-edge="Yes">Windy?
        <ul>
          <li data-edge="Yes">Raincoat</li>
          <li data-edge="No" data-chosen>Umbrella</li>
        </ul>
      </li>
      <li data-edge="No">Sunglasses</li>
    </ul>
  </li>
</ul>