← Diagrams & maps

Pipeline Status

A build pipeline runs before your eyes. Each stage spins amber while it runs, then pops green with a tick and fills the link to the next one. If a stage fails it turns red and shakes, the rest are skipped, and the badge at the top says where it broke.

Install Lint Build Test Deploy

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pipeline-status to a container with one child per stage, in order.
  3. Add data-fail="3" to make the third stage fail.

Options

Add any of these attributes to the same element.

data-failThe number of the stage that fails. Default: none, so the pipeline passes.
data-flakyEvery other run fails at a random stage. Good for demos with data-loop.
data-titleName shown at the top left. Default Pipeline.
data-stepAverage time each stage runs. Default 1100 ms.
data-timeOn a stage: its duration label, such as 42s. Default: a made-up time.
data-loopRun the pipeline again after a pause.

The stages share the width equally, so four to six short names fit a phone. Text size follows the container’s font-size.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pipeline Status: a CI/CD pipeline runs. Each stage spins while it runs,
    // turns green with a tick and fills the link to the next one, until the
    // pipeline passes, or a stage turns red and the rest are skipped.
    // Usage:
    //   <div data-pipeline-status>
    //     <span>Install</span><span>Build</span><span>Test</span><span>Deploy</span>
    //   </div>
    // One child per stage, in order.
    // Optional: data-fail (number of the stage that fails, e.g. 3; default
    // none), data-flaky (every other run fails at a random stage), data-step
    // (average ms each stage runs, default 1100), data-title (name shown in
    // the header, default "Pipeline"), data-loop (run again after a pause),
    // data-time on a stage (its duration label, e.g. "42s"; default made up).
    var DEFAULTS = { step: 1100, title: 'Pipeline' };

    var css =
      '.pip{--pip-ok:#10b981;--pip-run:#f59e0b;--pip-bad:#ef4444;--pip-idle:#d9d2e6;width:100%;box-sizing:border-box}' +
      '.pip-head{display:flex;align-items:center;justify-content:space-between;gap:.6em;margin-bottom:1.1em;font-weight:700}' +
      '.pip-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.pip-badge{display:inline-flex;align-items:center;gap:.4em;padding:.28em .75em;border-radius:99px;font-size:.86em;white-space:nowrap;background:#f1edf7;color:#6b6280;transition:background .3s,color .3s}' +
      '.pip-badge::before{content:"";width:.55em;height:.55em;border-radius:50%;background:currentColor}' +
      '.pip-badge[data-state=run]{background:#fef3c7;color:#b45309}' +
      '.pip-badge[data-state=run]::before{animation:pip-blink 1s infinite}' +
      '.pip-badge[data-state=ok]{background:#d1fae5;color:#047857}' +
      '.pip-badge[data-state=bad]{background:#fee2e2;color:#b91c1c}' +
      '.pip-row{display:flex;align-items:flex-start}' +
      '.pip-stage{position:relative;flex:1 1 0;min-width:0;display:flex;flex-direction:column;align-items:center;text-align:center}' +
      '.pip-link{position:absolute;top:1.25em;right:50%;width:100%;height:.22em;margin-top:-.11em;background:var(--pip-idle);border-radius:9px;overflow:hidden}' +
      '.pip-fill{position:absolute;inset:0;background:var(--pip-ok);transform-origin:left;transform:scaleX(0)}' +
      '.pip-node{position:relative;z-index:1;box-sizing:border-box;width:2.5em;height:2.5em;border-radius:50%;display:grid;place-items:center;background:#fff;border:.16em dashed var(--pip-idle);color:#fff;transition:background .3s,border-color .3s}' +
      '.pip-node svg{width:1.25em;height:1.25em;fill:none;stroke:currentColor;stroke-width:3.2;stroke-linecap:round;stroke-linejoin:round;opacity:0}' +
      '.pip-ring{position:absolute;inset:-.3em;border-radius:50%;border:.18em solid transparent;border-top-color:var(--pip-run);border-right-color:var(--pip-run);opacity:0}' +
      '.pip-name{margin-top:.55em;font-weight:700;line-height:1.15;overflow-wrap:anywhere;padding:0 .2em;transition:color .3s,opacity .3s}' +
      '.pip-time{margin-top:.15em;font-size:.8em;color:#8a819c;min-height:1.2em;font-variant-numeric:tabular-nums}' +
      '.pip-stage[data-state=run] .pip-node{border:.16em solid var(--pip-run)}' +
      '.pip-stage[data-state=run] .pip-ring{opacity:1;animation:pip-spin .8s linear infinite}' +
      '.pip-stage[data-state=run] .pip-node::after{content:"";width:.6em;height:.6em;border-radius:50%;background:var(--pip-run);animation:pip-blink 1s infinite}' +
      '.pip-stage[data-state=ok] .pip-node{background:var(--pip-ok);border:.16em solid var(--pip-ok)}' +
      '.pip-stage[data-state=bad] .pip-node{background:var(--pip-bad);border:.16em solid var(--pip-bad)}' +
      '.pip-stage[data-state=ok] .pip-ok,.pip-stage[data-state=bad] .pip-bad,.pip-stage[data-state=skip] .pip-skip{opacity:1}' +
      '.pip-stage[data-state=skip] .pip-node{border-style:solid;color:#b3aac4}' +
      '.pip-stage[data-state=skip] .pip-name{opacity:.45}' +
      '.pip-stage[data-state=bad] .pip-name{color:var(--pip-bad)}' +
      '.pip-stage[data-state=bad] .pip-time{color:var(--pip-bad)}' +
      '.pip-node svg{position:absolute}' +
      '@keyframes pip-spin{to{transform:rotate(360deg)}}' +
      '@keyframes pip-blink{50%{opacity:.35}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    var ICONS =
      '<svg class="pip-ok" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>' +
      '<svg class="pip-bad" viewBox="0 0 24 24"><path d="M7 7l10 10M17 7L7 17"/></svg>' +
      '<svg class="pip-skip" viewBox="0 0 24 24"><path d="M7 12h10"/></svg>';

    function fmt(sec) {
      return sec >= 60 ? Math.floor(sec / 60) + 'm ' + (sec % 60) + 's' : sec + 's';
    }

    function setup(el) {
      var d = el.dataset;
      var s = (el._pip = { stages: [], run: 0, timers: [] });
      s.step = +d.step || DEFAULTS.step;
      var kids = Array.from(el.children);
      el.classList.add('pip');
      var names = kids.map(function (k) { return k.textContent.trim(); });
      el.setAttribute('role', 'group');
      el.setAttribute('aria-label', (d.title || DEFAULTS.title) + ': ' + names.join(', '));
      var head = document.createElement('div');
      head.className = 'pip-head';
      head.innerHTML = '<span class="pip-title"></span><span class="pip-badge" role="status"></span>';
      head.firstChild.textContent = d.title || DEFAULTS.title;
      s.badge = head.lastChild;
      var row = document.createElement('div');
      row.className = 'pip-row';
      row.setAttribute('aria-hidden', 'true');
      kids.forEach(function (k, i) {
        var st = document.createElement('div');
        st.className = 'pip-stage';
        st.innerHTML = (i ? '<span class="pip-link"><span class="pip-fill"></span></span>' : '') +
          '<span class="pip-node"><span class="pip-ring"></span>' + ICONS + '</span><span class="pip-name"></span><span class="pip-time"></span>';
        st.querySelector('.pip-name').textContent = names[i];
        row.appendChild(st);
        s.stages.push({ el: st, fill: st.querySelector('.pip-fill'), node: st.querySelector('.pip-node'), time: st.querySelector('.pip-time'), label: k.getAttribute('data-time') });
        k.remove();
      });
      el.appendChild(head);
      el.appendChild(row);
      reset(el);
    }

    function setState(st, state) { st.el.setAttribute('data-state', state); }

    function badge(s, state, text) {
      s.badge.setAttribute('data-state', state);
      s.badge.textContent = text;
    }

    function reset(el) {
      var s = el._pip;
      s.stages.forEach(function (st) {
        setState(st, 'idle');
        st.time.textContent = '';
        if (st.fill) anime.utils.set(st.fill, { scaleX: 0 });
      });
      badge(s, 'idle', 'Queued');
    }

    // Which stage fails on this run (index), or -1.
    function failAt(el) {
      var d = el.dataset, s = el._pip, n = s.stages.length;
      if (d.fail) return Math.min(n, Math.max(1, +d.fail)) - 1;
      if (d.flaky !== undefined && s.run % 2 === 1) return 1 + Math.floor(Math.random() * (n - 1));
      return -1;
    }

    function later(s, fn, ms) { s.timers.push(setTimeout(fn, ms)); }

    function play(el) {
      var s = el._pip, fail = failAt(el), total = 0;
      s.run++;
      reset(el);
      badge(s, 'run', 'Running');
      function stage(i) {
        var st = s.stages[i];
        setState(st, 'run');
        var secs = 6 + Math.floor(Math.random() * 50);
        later(s, function () {
          total += secs;
          st.time.textContent = st.label || fmt(secs);
          if (i === fail) {
            setState(st, 'bad');
            anime.animate(st.node, { x: [0, -5, 5, -4, 4, 0], duration: 420, ease: 'inOut(2)' });
            s.stages.slice(i + 1).forEach(function (rest, j) {
              later(s, function () { setState(rest, 'skip'); rest.time.textContent = 'Skipped'; }, 160 + j * 90);
            });
            badge(s, 'bad', 'Failed at ' + st.el.querySelector('.pip-name').textContent);
            return done(el);
          }
          setState(st, 'ok');
          anime.animate(st.node, { scale: [0.6, 1], duration: 650, ease: 'outElastic(1, .55)' });
          var next = s.stages[i + 1];
          if (!next) {
            badge(s, 'ok', 'Passed in ' + fmt(total));
            return done(el);
          }
          anime.animate(next.fill, { scaleX: [0, 1], duration: 380, ease: 'inOut(2)' });
          later(s, function () { stage(i + 1); }, 380);
        }, s.step * (0.7 + Math.random() * 0.6));
      }
      later(s, function () { stage(0); }, 450);
    }

    function done(el) {
      var s = el._pip;
      if (el.dataset.loop === undefined) return;
      later(s, function () {
        anime.animate(el.querySelector('.pip-row'), {
          opacity: [1, 0.25, 1], duration: 700, ease: 'inOut(2)',
        });
        later(s, function () { play(el); }, 350);
      }, 2400);
    }

    function still(el) {
      var s = el._pip, fail = failAt(el);
      s.stages.forEach(function (st, i) {
        if (fail >= 0 && i > fail) { setState(st, 'skip'); st.time.textContent = 'Skipped'; return; }
        setState(st, i === fail ? 'bad' : 'ok');
        if (st.fill) anime.utils.set(st.fill, { scaleX: 1 });
        st.time.textContent = st.label || '';
      });
      if (fail >= 0) badge(s, 'bad', 'Failed');
      else badge(s, 'ok', 'Passed');
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-pipeline-status]').forEach(function (el) {
        if (el._pip) return;
        setup(el);
        if (reduced()) still(el);
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-pipeline-status data-title="main" data-fail="3">
  <span>Install</span>
  <span>Build</span>
  <span>Test</span>
  <span>Deploy</span>
</div>