← Forms

Progress Stepper

Numbered circles joined by lines. Step forward and the line to the next step grows across, the finished circle fills with colour and its number turns into a tick that draws itself, and the next circle lights up with a bounce and a soft pulse. Step back and it all runs in reverse.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-progress-stepper and list the step names in data-steps.
  3. Move it from your own code with animacious.progressStepper(el, 3), or add data-controls for Back and Next buttons.

Options

Add any of these attributes to the same element.

data-stepsThe step names, comma-separated. Default Cart,Details,Payment,Done.
data-currentThe step you’re on, counting from 1. One past the last step means everything’s done. Default 1.
data-controlsAdds Back and Next buttons underneath.
data-colorColour of finished steps and lines. Default #6a0dad.
data-autoplayIt steps forward to the end and back again by itself while in view, and stops as soon as you press a button.

The steps are an ordered list and the current one has aria-current="step"; each move is read out politely. Listen for the change event: e.detail.step is the new step.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Progress Stepper: numbered step circles joined by lines. Move forward
    // and the line to the next step grows across, the finished circle fills
    // with colour and its number turns into a tick that draws itself, and the
    // next circle lights up with a bounce and a soft pulse. Going back runs
    // it all in reverse.
    // Usage: <div data-progress-stepper data-steps="Cart,Details,Payment,Done"></div>
    // Optional: data-steps (the step names, comma-separated, default
    // "Cart,Details,Payment,Done"), data-current (the step you're on, from 1;
    // one past the last means all done; default 1), data-controls (adds Back
    // and Next buttons underneath), data-color (default #6a0dad),
    // data-autoplay (it steps forward to the end and back again by itself
    // while in view; it stops as soon as you use the buttons).
    // Script use: animacious.progressStepper(el, 3) moves to step 3.
    // Listen for the "change" event: e.detail.step is the new step.
    var DEFAULTS = { steps: 'Cart,Details,Payment,Done', color: '#6a0dad' };

    var css =
      '.pgs{display:flex;flex-direction:column;align-items:stretch;gap:1.4em;box-sizing:border-box;padding:0 1.6em;color:#18181b}' +
      '.pgs-row{display:flex;align-items:center;margin:0;padding:0 0 1.7em;list-style:none}' +
      '.pgs-step{position:relative;flex:none;width:2.3em;height:2.3em}' +
      '.pgs-dot{position:absolute;inset:0;display:grid;place-items:center;box-sizing:border-box;border-radius:50%;background:#fff;' +
      'border:2px solid #dcdce1;color:#8a8a93;font-weight:700;font-variant-numeric:tabular-nums;transition:border-color .3s,color .3s}' +
      '.pgs-fill{position:absolute;inset:-2px;border-radius:50%;background:var(--pgs-color);transform:scale(0)}' +
      '.pgs-num{position:relative}' +
      '.pgs-check{position:absolute;left:50%;top:50%;width:1.3em;height:1.3em;margin:-.65em 0 0 -.65em;fill:none;stroke:#fff;stroke-width:3;' +
      'stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:19;stroke-dashoffset:19}' +
      '.pgs-pulse{position:absolute;inset:-2px;border-radius:50%;border:2px solid var(--pgs-color);opacity:0;pointer-events:none}' +
      '.pgs-label{position:absolute;left:50%;top:100%;margin-top:.45em;transform:translateX(-50%);font-size:.82em;font-weight:600;color:#a1a1aa;' +
      'white-space:nowrap;transition:color .3s}' +
      '.pgs-current .pgs-dot{border-color:var(--pgs-color);color:var(--pgs-color)}' +
      '.pgs-done .pgs-dot{border-color:var(--pgs-color)}' +
      '.pgs-current .pgs-label,.pgs-done .pgs-label{color:#27272a}' +
      '.pgs-line{position:relative;flex:1;height:3px;margin:0 .35em;border-radius:3px;background:#e4e4e7;overflow:hidden}' +
      '.pgs-line i{position:absolute;inset:0;background:var(--pgs-color);transform:scaleX(0);transform-origin:0 50%}' +
      '.pgs-controls{display:flex;justify-content:center;gap:.6em}' +
      '.pgs-btn{min-width:6em;margin:0;padding:.65em 1.2em;border:0;border-radius:2em;background:var(--pgs-color);color:#fff;font:inherit;font-weight:600;' +
      'cursor:pointer;transition:opacity .2s;touch-action:manipulation}' +
      '.pgs-btn.pgs-back{background:#fff;color:#27272a;box-shadow:inset 0 0 0 1.5px #dcdce1}' +
      '.pgs-btn:disabled{opacity:.4;cursor:default}' +
      '.pgs-btn:focus-visible{outline:2px solid var(--pgs-color);outline-offset:2px}' +
      '.pgs-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    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 setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var names = (d.steps || DEFAULTS.steps).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
      var n = names.length;
      var current = Math.max(1, Math.min(n + 1, parseInt(d.current, 10) || 1));
      el.classList.add('pgs');
      el.style.setProperty('--pgs-color', d.color || DEFAULTS.color);
      el.textContent = '';
      var row = document.createElement(mock ? 'div' : 'ol');
      row.className = 'pgs-row';
      el.appendChild(row);
      var steps = [], lines = [];
      names.forEach(function (name, i) {
        var s = document.createElement(mock ? 'div' : 'li');
        s.className = 'pgs-step';
        s.innerHTML = '<span class="pgs-pulse"></span><span class="pgs-dot"><span class="pgs-fill"></span><span class="pgs-num" aria-hidden="true">' + (i + 1) +
          '</span><svg class="pgs-check" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12.5l4 4 8-9"/></svg></span><span class="pgs-label"></span>';
        s.querySelector('.pgs-label').textContent = name;
        row.appendChild(s);
        var st = { el: s, fill: s.querySelector('.pgs-fill'), num: s.querySelector('.pgs-num'), check: s.querySelector('.pgs-check'), pulse: s.querySelector('.pgs-pulse'), sr: null, state: '' };
        if (!mock) {
          st.sr = document.createElement('span');
          st.sr.className = 'pgs-sr';
          s.appendChild(st.sr);
        }
        steps.push(st);
        if (i < n - 1) {
          var line = document.createElement(mock ? 'span' : 'li');
          line.className = 'pgs-line';
          line.setAttribute('aria-hidden', 'true');
          line.innerHTML = '<i></i>';
          row.appendChild(line);
          lines.push(line.firstChild);
        }
      });
      var back = null, next = null, live = null;
      if (!mock) {
        row.setAttribute('aria-label', 'Progress');
        live = document.createElement('span');
        live.className = 'pgs-sr';
        live.setAttribute('aria-live', 'polite');
        el.appendChild(live);
        if (on(d.controls)) {
          var ctr = document.createElement('div');
          ctr.className = 'pgs-controls';
          back = document.createElement('button');
          next = document.createElement('button');
          back.type = next.type = 'button';
          back.className = 'pgs-btn pgs-back';
          next.className = 'pgs-btn';
          back.textContent = 'Back';
          next.textContent = 'Next';
          ctr.appendChild(back);
          ctr.appendChild(next);
          el.appendChild(ctr);
          back.addEventListener('click', function () { stop(); go(current - 1); });
          next.addEventListener('click', function () { stop(); go(current + 1); });
        }
      }

      function setState(i, state, motion) {
        var st = steps[i], prev = st.state;
        if (prev === state) return;
        st.state = state;
        st.el.classList.toggle('pgs-done', state === 'done');
        st.el.classList.toggle('pgs-current', state === 'current');
        if (st.sr) st.sr.textContent = state === 'done' ? ', done' : state === 'current' ? ', current step' : '';
        if (state === 'current') st.el.setAttribute('aria-current', 'step');
        else st.el.removeAttribute('aria-current');
        [st.fill, st.num, st.check, st.el, st.pulse].forEach(function (t) { anime.utils.remove(t); });
        var done = state === 'done';
        if (!motion) {
          anime.utils.set(st.fill, { scale: done ? 1 : 0 });
          anime.utils.set(st.num, { opacity: done ? 0 : 1, scale: 1 });
          anime.utils.set(st.check, { strokeDashoffset: done ? 0 : 19 });
          anime.utils.set(st.el, { scale: 1 });
          return;
        }
        if (done) {
          anime.animate(st.fill, { scale: [0, 1], duration: 520, ease: 'outElastic(1, .7)' });
          anime.animate(st.num, { opacity: 0, scale: 0.4, duration: 180, ease: 'in(2)' });
          anime.animate(st.check, { strokeDashoffset: [19, 0], duration: 320, delay: 170, ease: 'out(2)' });
        } else {
          if (prev === 'done') {
            anime.animate(st.check, { strokeDashoffset: 19, duration: 180, ease: 'in(2)' });
            anime.animate(st.fill, { scale: 0, duration: 260, delay: 80, ease: 'in(2)' });
            anime.animate(st.num, { opacity: [0, 1], scale: [0.5, 1], duration: 300, delay: 220, ease: 'out(3)' });
          }
          if (state === 'current') {
            anime.animate(st.el, { scale: [0.8, 1.12, 1], duration: 520, ease: 'out(3)' });
            anime.animate(st.pulse, { scale: [1, 1.7], opacity: [0.55, 0], duration: 900, delay: 150, ease: 'out(2)' });
          }
        }
      }
      function setLine(i, full, motion) {
        var l = lines[i];
        if (!l) return;
        anime.utils.remove(l);
        if (motion) anime.animate(l, { scaleX: full ? 1 : 0, duration: full ? 380 : 260, ease: full ? 'inOut(2)' : 'in(2)' });
        else anime.utils.set(l, { scaleX: full ? 1 : 0 });
      }

      var timers = [];
      function at(ms, fn) { timers.push(setTimeout(fn, ms)); }
      function stateFor(i, cur) { return i < cur - 1 ? 'done' : i === cur - 1 ? 'current' : 'todo'; }
      function syncButtons() {
        if (back) back.disabled = current <= 1;
        if (next) { next.disabled = current > n; next.textContent = current >= n ? 'Finish' : 'Next'; }
      }
      function go(target, quiet) {
        target = Math.max(1, Math.min(n + 1, target));
        if (target === current) return;
        timers.forEach(clearTimeout);
        timers = [];
        var from = current, motion = !reduced();
        current = target;
        syncButtons();
        if (!motion) {
          steps.forEach(function (s, i) { setState(i, stateFor(i, current), false); setLine(i, i < current - 1, false); });
        } else if (target > from) {
          // Forward: finish each step, grow the line, light the next one.
          var t = 0;
          for (var i = from - 1; i < target - 1; i++) {
            (function (i, t) {
              at(t, function () { setState(i, 'done', true); });
              at(t + 120, function () { setLine(i, true, true); });
              if (i + 1 === target - 1 && i + 1 < n) at(t + 420, function () { setState(i + 1, 'current', true); });
            })(i, t);
            t += 420;
          }
        } else {
          // Backward: undo in reverse, faster.
          var u = 0;
          for (var j = from - 1; j >= target - 1; j--) {
            (function (j, u) {
              if (j < n) at(u, function () { setState(j, j === target - 1 ? 'current' : 'todo', true); });
              if (j > target - 1) at(u + 60, function () { setLine(j - 1, false, true); });
            })(j, u);
            u += 220;
          }
        }
        if (live) live.textContent = current > n ? 'All steps done' : 'Step ' + current + ' of ' + n + ': ' + names[current - 1];
        if (!quiet && !mock) el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { step: current } }));
      }
      steps.forEach(function (s, i) { setState(i, stateFor(i, current), false); setLine(i, i < current - 1, false); });
      syncButtons();
      el._pgsGo = go;

      // Autoplay: steps forward to the end, holds, then runs back to the start.
      var stopped = false, timer = null;
      function stop() { stopped = true; clearTimeout(timer); }
      function run() {
        if (stopped) return;
        if (current <= n) { go(current + 1, true); timer = setTimeout(run, current > n ? 2000 : 1100); }
        else { go(1, true); timer = setTimeout(run, n * 220 + 900); }
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          if (stopped) return;
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(run, 800);
        }).observe(el);
      }
    }

    window.animacious = window.animacious || {};
    window.animacious.progressStepper = function (el, step) { if (el && el._pgsGo) el._pgsGo(step); };

    function init() {
      document.querySelectorAll('[data-progress-stepper]').forEach(function (el) {
        if (el._pgs) return;
        el._pgs = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-progress-stepper data-steps="Cart,Details,Payment,Done" data-current="2"></div>

<script>
  // later, when the payment form is done:
  animacious.progressStepper(document.querySelector('[data-progress-stepper]'), 4);
</script>