Status Pill Morph
A status pill morphs from Pending to Running to Done. Its colour fades across, it eases to the width of each word, the old label rolls up and out as the new one rolls in, and each state brings its own icon: a ticking clock, a spinner and a freshly drawn tick.
Build
Test
Deploy
How to use it
- Paste the snippet just before
</head>. - Add an empty
<span data-status-pill-morph></span>. It sizes withfont-size. - Add
data-manualand callanimacious.statusPill(el, "running")and(el, "done")as your job moves along. Without it, it runs through by itself when it scrolls into view.
Options
Add any of these attributes to the same element.
data-status | Where it starts: pending, running or done. Default pending. |
data-labels | Three comma-separated labels. Default Pending,Running,Done. |
data-colors | Three comma-separated colours. Default #d97706,#2563eb,#16a34a. |
data-delay | How long it waits as pending before it starts running. Default 1200 ms. |
data-step | How long it runs before it’s done. Default 1800 ms. |
data-manual | Stay put until a script call changes it. |
data-loop | Go back to pending and run again. |
data-hold | How long it shows done before looping. Default 2200 ms. |
The pill is a status region labelled with its current state, so screen readers hear each change. Give pills in a row different delays to make a pipeline, as above. With reduced motion it swaps states without moving.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Status Pill Morph: a status pill that morphs between Pending, Running
// and Done. Its colour fades across, it eases to the width of each word,
// the old label rolls up and out as the new one rolls in, and each state
// has its own little icon: a ticking clock, a spinner and a drawn tick.
// Usage: <span data-status-pill-morph></span>
// Optional: data-status (start state: pending, running or done; default
// pending), data-labels (three comma-separated labels, default
// "Pending,Running,Done"), data-colors (three comma-separated colours,
// default "#d97706,#2563eb,#16a34a"), data-delay (ms before it starts
// running, default 1200), data-step (ms it runs before done, default
// 1800), data-manual (wait for script calls), data-loop (go back to
// pending and run again), data-hold (ms it shows done before looping,
// default 2200).
// Script use: animacious.statusPill(el, 'running') or 'done' or 'pending'.
var DEFAULTS = { labels: 'Pending,Running,Done', colors: '#d97706,#2563eb,#16a34a', delay: 1200, step: 1800, hold: 2200 };
var NAMES = ['pending', 'running', 'done'];
var ICONS = [
'<circle cx="12" cy="12" r="9"/><path d="M12 12l3.2 2"/><path class="spm-hand" d="M12 12V7"/>',
'<circle cx="12" cy="12" r="9" opacity=".25"/><path class="spm-spin" d="M21 12a9 9 0 0 0-9-9"/>',
'<circle cx="12" cy="12" r="9"/><path class="spm-tick" d="M8 12.4l2.8 2.8L16.2 9.6"/>'
];
var css =
'.spm{position:relative;display:inline-flex;align-items:center;height:1.9em;padding:0 .8em 0 .6em;border-radius:99px;box-sizing:border-box;' +
'background:color-mix(in srgb,currentColor 13%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 28%,transparent);' +
'font-weight:700;line-height:1;white-space:nowrap;overflow:hidden;vertical-align:middle}' +
'.spm-in{display:inline-flex;align-items:center;gap:.4em}' +
'.spm-out{position:absolute;left:.6em;top:0;bottom:0}' +
'.spm svg{width:1.1em;height:1.1em;flex:none;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round;overflow:visible}' +
'.spm-hand,.spm-spin{transform-box:view-box;transform-origin:12px 12px}' +
'.spm-hand{animation:spm-turn 3s linear infinite}' +
'.spm-spin{animation:spm-turn .8s linear infinite}' +
'@keyframes spm-turn{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion: reduce){.spm-hand,.spm-spin{animation:none}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function opt(el, k) { var v = el.dataset[k]; return v === undefined ? DEFAULTS[k] : v; }
function list(el, k) { var a = String(opt(el, k)).split(','), b = DEFAULTS[k].split(','); return [0, 1, 2].map(function (i) { return (a[i] || b[i]).trim(); }); }
function index(state) { var i = NAMES.indexOf(String(state).toLowerCase()); return i < 0 ? (+state >= 0 && +state <= 2 ? +state : 0) : i; }
function content(el, i) {
var span = document.createElement('span');
span.className = 'spm-in';
span.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + ICONS[i] + '</svg><span></span>';
span.lastChild.textContent = list(el, 'labels')[i];
return span;
}
function show(el, i, instant) {
var s = el._spm;
if (s.i === i) return;
var first = s.i < 0;
s.i = i;
var color = list(el, 'colors')[i];
var next = content(el, i), old = s.cur;
s.cur = next;
el.setAttribute('aria-label', 'Status: ' + list(el, 'labels')[i]);
if (instant || first || reduced()) {
el.style.color = color;
el.textContent = '';
el.appendChild(next);
return;
}
var cs = getComputedStyle(el);
var from = el.offsetWidth;
old.classList.add('spm-out');
el.appendChild(next);
var to = next.offsetWidth + parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
if (s.size) s.size.pause();
s.size = anime.animate(el, { width: [from, to], duration: 480, ease: 'out(3)', onComplete: function () { el.style.width = ''; } });
anime.animate(el, { color: color, duration: 420, ease: 'out(2)' });
anime.animate(el, { scale: [{ to: 1.06, duration: 150, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'outElastic(1, .5)' }] });
anime.animate(old, { opacity: 0, y: '-1em', duration: 260, ease: 'in(2)', onComplete: function () { old.remove(); } });
anime.animate(next, { opacity: [0, 1], y: ['1em', '0em'], duration: 420, delay: 120, ease: 'out(3)' });
anime.animate(next.querySelector('svg'), { scale: [0.3, 1], rotate: [-90, 0], duration: 600, delay: 140, ease: 'outBack(2)' });
var tick = next.querySelector('.spm-tick');
if (tick) {
var d = anime.svg.createDrawable(tick);
anime.utils.set(d, { draw: '0 0' });
anime.animate(d, { draw: ['0 0', '0 1'], duration: 380, delay: 380, ease: 'out(3)' });
}
}
function cycle(el) {
var s = el._spm, d = el.dataset;
clearTimeout(s.timer);
if (!s.visible) { s.waiting = true; return; }
s.waiting = false;
var start = s.i;
if (start === 2) { show(el, 0); start = 0; }
s.timer = setTimeout(function () {
if (start < 1) show(el, 1);
s.timer = setTimeout(function () {
show(el, 2);
if (d.loop === undefined || d.loop === 'false') return;
s.timer = setTimeout(function () { cycle(el); }, +opt(el, 'hold'));
}, +opt(el, 'step'));
}, start < 1 ? +opt(el, 'delay') : 0);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._spm;
s.visible = e.isIntersecting;
if (!s.visible) return;
if (!s.started) { s.started = true; if (e.target.dataset.manual === undefined) cycle(e.target); }
else if (s.waiting) cycle(e.target);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-status-pill-morph]').forEach(function (el) {
if (el._spm) return;
el._spm = { i: -1, cur: null, timer: null, visible: false, waiting: false };
el.classList.add('spm');
el.setAttribute('role', 'status');
var start = index(el.dataset.status || 'pending');
show(el, reduced() && el.dataset.manual === undefined ? 2 : start, true);
if (!reduced() && (start < 2 || (el.dataset.loop !== undefined && el.dataset.loop !== 'false'))) io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.statusPill = function (el, state) {
if (!el._spm) return;
clearTimeout(el._spm.timer);
show(el, index(state));
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><span data-status-pill-morph></span>
<!-- your own words and colours, driven by your code: -->
<span id="job" data-status-pill-morph data-manual data-labels="Queued,Uploading,Live" data-colors="#64748b,#7c3aed,#059669"></span>
<script>
const job = document.getElementById("job");
animacious.statusPill(job, "running");
upload().then(() => animacious.statusPill(job, "done"));
</script>