← Feedback

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

  1. Paste the snippet just before </head>.
  2. Add an empty <span data-status-pill-morph></span>. It sizes with font-size.
  3. Add data-manual and call animacious.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-statusWhere it starts: pending, running or done. Default pending.
data-labelsThree comma-separated labels. Default Pending,Running,Done.
data-colorsThree comma-separated colours. Default #d97706,#2563eb,#16a34a.
data-delayHow long it waits as pending before it starts running. Default 1200 ms.
data-stepHow long it runs before it’s done. Default 1800 ms.
data-manualStay put until a script call changes it.
data-loopGo back to pending and run again.
data-holdHow 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.

1 · Snippet
<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>
2 · Your HTML
<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>