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
- Paste the snippet just before
</head>. - Add
data-pipeline-statusto a container with one child per stage, in order. - Add
data-fail="3"to make the third stage fail.
Options
Add any of these attributes to the same element.
data-fail | The number of the stage that fails. Default: none, so the pipeline passes. |
data-flaky | Every other run fails at a random stage. Good for demos with data-loop. |
data-title | Name shown at the top left. Default Pipeline. |
data-step | Average time each stage runs. Default 1100 ms. |
data-time | On a stage: its duration label, such as 42s. Default: a made-up time. |
data-loop | Run 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.
<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><div data-pipeline-status data-title="main" data-fail="3">
<span>Install</span>
<span>Build</span>
<span>Test</span>
<span>Deploy</span>
</div>