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
- Paste the snippet just before
</head>. - Add an empty element with
data-progress-stepperand list the step names indata-steps. - Move it from your own code with
animacious.progressStepper(el, 3), or adddata-controlsfor Back and Next buttons.
Options
Add any of these attributes to the same element.
data-steps | The step names, comma-separated. Default Cart,Details,Payment,Done. |
data-current | The step you’re on, counting from 1. One past the last step means everything’s done. Default 1. |
data-controls | Adds Back and Next buttons underneath. |
data-color | Colour of finished steps and lines. Default #6a0dad. |
data-autoplay | It 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.
<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><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>