← Diagrams & maps

Flowchart Build

A flowchart that builds itself step by step: each box pops in, then an arrow draws across to the next one. It fits itself to the width, snaking down in rows when there isn’t room for one line.

New idea Sketch it Test with users Works? Build Launch

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-flowchart-build to a container with one child per step, in order.
  3. Give a step data-shape="decision" for a diamond, or data-shape="pill" / "box".

Options

Add any of these attributes to the same element.

data-shapeOn a step: box, pill or decision. Default: pills for the first and last steps, boxes between.
data-colorColour of the boxes and arrows. Default #a855f7.
data-columnsSteps per row. Default: as many as fit the width.
data-stepTime for each step (box plus arrow). Default 650 ms.
data-loopFade out and build again.

Text size follows the element’s font-size, and the boxes grow with it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Flowchart Build: a flowchart that builds itself step by step. Each box
    // pops in, then an arrow draws across to the next one. It lays itself out
    // to fit the width: one row when there's room, otherwise it snakes down in
    // rows (left to right, then right to left).
    // Usage:
    //   <div data-flowchart-build>
    //     <span>Idea</span>
    //     <span>Sketch</span>
    //     <span data-shape="decision">Looks good?</span>
    //     <span>Ship it</span>
    //   </div>
    // Shapes (data-shape on a step): "box" (default), "pill" (the first and last
    // steps unless you say otherwise) or "decision" (a diamond).
    // Text size follows the element's font-size.
    // Optional: data-color (default #a855f7), data-columns (steps per row;
    // default fits the width), data-step (ms per step, default 650), data-loop
    // (fade out and build again).
    var DEFAULTS = { color: '#a855f7', step: 650, hold: 2400, ink: '#1e1b2e' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.fcb{display:flex;align-items:center;justify-content:center}' +
      '.fcb-svg{display:block;width:100%;height:auto;max-height:100%;overflow:visible}' +
      '.fcb-node,.fcb-head{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 width(t, text, fs) {
      var w = 0;
      try { w = t.getComputedTextLength(); } catch (e) {}
      return w || text.length * fs * 0.56;
    }

    // Centred text that wraps onto two lines, then shrinks, 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 = width(t, text, fs);
      var words = text.split(' ');
      if (w > maxW && words.length > 1) {
        var best = 1, bestW = Infinity;
        for (var i = 1; i < words.length; i++) {
          var a = words.slice(0, i).join(' '), b = words.slice(i).join(' ');
          var m = Math.max(a.length, b.length);
          if (m < bestW) { bestW = m; best = i; }
        }
        t.textContent = '';
        var l1 = mk('tspan', { x: x, dy: '-0.6em' }, t);
        l1.textContent = words.slice(0, best).join(' ');
        var l2 = mk('tspan', { x: x, dy: '1.2em' }, t);
        l2.textContent = words.slice(best).join(' ');
        w = Math.max(width(l1, l1.textContent, fs), width(l2, l2.textContent, fs));
      }
      if (w > maxW) t.setAttribute('font-size', Math.max(fs * 0.7, (fs * maxW) / w));
      return t;
    }

    function parse(el) {
      var kids = Array.from(el.children);
      return kids.map(function (c, i) {
        var shape = c.dataset.shape || (i === 0 || i === kids.length - 1 ? 'pill' : 'box');
        return { text: c.textContent.trim(), shape: shape };
      });
    }

    function render(el) {
      var s = el._fcb, d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var W = el.clientWidth || 320;
      var fs = parseFloat(getComputedStyle(el).fontSize) || 14;
      var n = s.steps.length;
      s.w = el.clientWidth;
      var cols = +d.columns || Math.floor(W / (fs * 9));
      cols = Math.max(1, Math.min(n, cols));
      var rows = Math.ceil(n / cols);
      var cellW = W / cols, rowH = fs * 6.4;
      var bw = Math.min(cellW - fs * 2.4, fs * 10), bh = fs * 3;
      var H = rows * rowH - (rowH - fs * 4.6);

      el.textContent = '';
      var svg = mk('svg', { class: 'fcb-svg', viewBox: '0 0 ' + W + ' ' + H, width: W, height: H, 'aria-hidden': 'true' }, el);
      var root = mk('g', {}, svg);
      var edgeLayer = mk('g', {}, root);

      var pts = s.steps.map(function (st, i) {
        var r = Math.floor(i / cols), c = i % cols;
        if (r % 2) c = cols - 1 - c;
        return { x: (c + 0.5) * cellW, y: fs * 2.3 + r * rowH, row: r };
      });

      // Half extents of a step, so arrows stop at its edge.
      function half(st) {
        return st.shape === 'decision' ? { w: Math.min(bw, fs * 7.6) / 2 + 2, h: fs * 2.2 + 2 } : { w: bw / 2 + 2, h: bh / 2 + 2 };
      }

      s.nodes = s.steps.map(function (st, i) {
        var p = pts[i];
        var g = mk('g', { class: 'fcb-node' }, root);
        var filled = st.shape === 'pill';
        if (st.shape === 'decision') {
          var dw = Math.min(bw, fs * 7.6) / 2, dh = fs * 2.2;
          mk('polygon', { points: [p.x, p.y - dh, p.x + dw, p.y, p.x, p.y + dh, p.x - dw, p.y].join(' '), fill: '#fff', stroke: color, 'stroke-width': 2, 'stroke-linejoin': 'round' }, g);
          label(g, st.text, p.x, p.y, dw * 1.1, fs * 0.92, DEFAULTS.ink);
        } else {
          mk('rect', { x: p.x - bw / 2, y: p.y - bh / 2, width: bw, height: bh, rx: filled ? bh / 2 : fs * 0.5, fill: filled ? color : '#fff', stroke: color, 'stroke-width': 2 }, g);
          label(g, st.text, p.x, p.y, bw - fs * 1.2, fs, filled ? '#fff' : DEFAULTS.ink);
        }
        return g;
      });

      s.edges = [];
      for (var i = 0; i < n - 1; i++) {
        var a = pts[i], b = pts[i + 1], ha = half(s.steps[i]), hb = half(s.steps[i + 1]);
        var x1, y1, x2, y2, ang;
        if (a.row === b.row) {
          var dir = b.x > a.x ? 1 : -1;
          x1 = a.x + dir * ha.w; x2 = b.x - dir * hb.w; y1 = y2 = a.y;
          ang = dir > 0 ? 0 : 180;
        } else {
          x1 = x2 = a.x; y1 = a.y + ha.h; y2 = b.y - hb.h; ang = 90;
        }
        var hs = fs * 0.5;
        var line = mk('path', { d: 'M' + x1 + ',' + y1 + ' L' + (x2 - Math.cos(ang * Math.PI / 180) * hs) + ',' + (y2 - Math.sin(ang * Math.PI / 180) * hs), fill: 'none', stroke: color, 'stroke-width': 2, 'stroke-linecap': 'round' }, edgeLayer);
        // The head sits in a wrapper so anime can scale the wrapper while the
        // head keeps its own position and rotation.
        var hg = mk('g', { class: 'fcb-head' }, edgeLayer);
        mk('polygon', { points: [0, 0, -hs * 1.4, -hs, -hs * 1.4, hs].join(' '), fill: color, transform: 'translate(' + x2 + ',' + y2 + ') rotate(' + ang + ')' }, hg);
        s.edges.push({ line: line, head: hg });
      }
      s.root = root;
    }

    function hide(s) {
      anime.utils.set(s.root, { opacity: 1 });
      anime.utils.set(s.nodes, { opacity: 0, scale: 0.6 });
      s.edges.forEach(function (e) {
        anime.utils.set(anime.svg.createDrawable(e.line), { draw: '0 0' });
        anime.utils.set(e.head, { opacity: 0, scale: 0 });
      });
    }

    function start(el) {
      var s = el._fcb;
      var step = +el.dataset.step || DEFAULTS.step;
      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 t = 0;
      s.nodes.forEach(function (node, i) {
        tl.add(node, { opacity: [0, 1], scale: [0.6, 1], duration: 480, ease: 'outBack(1.8)' }, t);
        var e = s.edges[i];
        if (e) {
          tl.add(anime.svg.createDrawable(e.line), { draw: ['0 0', '0 1'], duration: step * 0.55, ease: 'inOut(2)' }, t + step * 0.4);
          tl.add(e.head, { opacity: [0, 1], scale: [0, 1], duration: 220, ease: 'out(3)' }, t + step * 0.9);
        }
        t += step;
      });
      if (s.loop) tl.add(s.root, { opacity: [1, 0], duration: 500, ease: 'in(2)' }, t + DEFAULTS.hold);
    }

    function setup(el) {
      var s = (el._fcb = { steps: parse(el), loop: on(el.dataset.loop), tl: null, visible: false, done: false });
      el.classList.add('fcb');
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Flowchart: ' + s.steps.map(function (x) { return x.text; }).join(', then '));
      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-flowchart-build]').forEach(function (el) {
        if (el._fcb) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-flowchart-build style="font-size: 14px">
  <span>Idea</span>
  <span>Sketch</span>
  <span data-shape="decision">Looks good?</span>
  <span>Ship it</span>
</div>