Push-along Progress
A little figure leans into the end of a progress bar and pushes it along. Its legs go when the bar moves and it stops for a breather when progress stalls. At 100% it throws its arms up and hops for joy. On its own it fills, cheers and starts over. Give it a data-value and it becomes a progress meter that follows your value. Colour the bar with color.
Uploading photos
Moving files
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-push-along-progress>where the bar should appear. It fills the width of its container and fills up on a loop. - Or, to show real progress, add
data-value(0 to 100) and update it from your code:bar.dataset.value = 72.
Options
Add any of these attributes to the same element.
data-value | Turn it into a progress meter filled to this percentage. Change it and the figure pushes the bar there. |
data-duration | Time to fill the bar when looping. Default 4200 ms. |
data-figure | The figure’s colour. Default #334155. |
data-label="false" | Hide the percentage. |
The figure is about 1em tall, so font-size sets the height. It’s a progressbar for screen readers, with aria-valuenow kept up to date. It only moves while it’s on screen. With reduced motion the figure stands still and the bar shows its value.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Push-along Progress: a little figure leans into the end of a progress
// bar and pushes it along, legs going when it moves and stopping for a
// breather when it stalls. At 100% it throws its arms up and hops for joy.
// On its own it fills, cheers and starts over. Give it a data-value and it
// becomes a progress meter that follows your value.
// Sized with font-size (the figure is about 1em tall; the bar is as wide
// as its container), the bar coloured with color.
// It only moves while it's on screen.
// Usage: <div data-push-along-progress aria-label="Uploading"></div>
// or: <div data-push-along-progress data-value="40"></div>
// (then set data-value, 0 to 100, from your code as things progress)
// Optional: data-duration (ms to fill when looping, default 4200),
// data-figure (the figure's colour, default #334155), data-label="false"
// (hide the percentage).
var DEFAULTS = { duration: 4200, figure: '#334155' };
var FW = 0.72, CW = 0.3; // em: figure width, end-cap width
var css =
'.pap{display:flex;align-items:flex-end;gap:.4em;width:100%;text-align:left}' +
'.pap-scene{position:relative;flex:1;min-width:0;height:1.5em}' +
'.pap-track,.pap-fill{position:absolute;left:0;bottom:0;height:.4em;border-radius:.2em}' +
'.pap-track{right:0;background:currentColor;opacity:.16}' +
'.pap-fill{background:currentColor;border-radius:.2em 0 0 .2em}' +
'.pap-cap{position:absolute;bottom:0;width:' + CW + 'em;height:1.08em;border-radius:.12em .12em .08em .08em;background:currentColor;box-shadow:inset -.07em 0 rgba(0,0,0,.18)}' +
'.pap-fig{position:absolute;bottom:.4em;width:' + FW + 'em;height:1.02em;overflow:visible}' +
'.pap-fig *{stroke-linecap:round;stroke-linejoin:round}' +
'.pap-label{flex:none;min-width:2.5em;padding-bottom:.02em;font-size:.55em;font-weight:700;font-variant-numeric:tabular-nums;text-align:right;line-height:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function make(parent, tag, attrs) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
parent.appendChild(n);
return n;
}
function span(parent, cls) {
var s = document.createElement('span');
s.className = cls;
parent.appendChild(s);
return s;
}
function clampV(v) { return Math.max(0, Math.min(100, isFinite(v) ? v : 0)); }
function inOut(v) { return 0.5 - 0.5 * Math.cos(Math.PI * v); }
function mix(a, b, t) { return a + (b - a) * t; }
// Bursts of different lengths with short stalls between, so it feels real.
function plan(duration) {
var stops = [0], n = 5, i;
for (i = 1; i < n; i++) stops.push(Math.round(100 * (i / n) + (Math.random() - 0.5) * 12));
stops.push(100);
var segs = [], total = 0;
for (i = 1; i < stops.length; i++) {
var w = (stops[i] - stops[i - 1]) / 100 * (0.6 + Math.random() * 0.8);
var pause = i < stops.length - 1 ? 0.05 + Math.random() * 0.12 : 0;
segs.push({ from: stops[i - 1], to: stops[i], ms: w, pause: pause });
total += w + pause;
}
segs.forEach(function (s) { s.ms *= duration / total; s.pause *= duration / total; });
return segs;
}
function build(el) {
var d = el.dataset;
var meter = d.value != null && d.value !== '';
el.classList.add('pap');
el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.setAttribute('aria-valuemin', '0');
el.setAttribute('aria-valuemax', '100');
el.textContent = '';
var scene = span(el, 'pap-scene');
scene.setAttribute('aria-hidden', 'true');
span(scene, 'pap-track');
var fill = span(scene, 'pap-fill');
var cap = span(scene, 'pap-cap');
var fig = make(scene, 'svg', { class: 'pap-fig', viewBox: '0 0 24 34' });
var g = make(fig, 'g', { fill: 'none', stroke: d.figure || DEFAULTS.figure, 'stroke-width': 2.6 });
var legB = make(g, 'polyline', { opacity: 0.6 });
var armB = make(g, 'polyline', { opacity: 0.6 });
var torso = make(g, 'line', { 'stroke-width': 3.4 });
var legF = make(g, 'polyline', {});
var armF = make(g, 'polyline', {});
var head = make(g, 'circle', { r: 3.7, fill: d.figure || DEFAULTS.figure, stroke: 'none' });
var label = d.label === 'false' ? null : span(el, 'pap-label');
if (label) label.setAttribute('aria-hidden', 'true');
var shown = -1;
function setP(p) {
var k = (p / 100).toFixed(4);
var reach = 'calc((100% - ' + (FW + CW) + 'em) * ' + k + ')';
fill.style.width = 'calc(' + reach + ' + ' + FW + 'em)';
cap.style.left = 'calc(' + reach + ' + ' + FW + 'em)';
fig.style.left = reach;
var r = Math.floor(p);
if (r !== shown) {
shown = r;
el.setAttribute('aria-valuenow', String(r));
if (label) label.textContent = r + '%';
}
}
function pts(list) { return list.map(function (q) { return q[0].toFixed(2) + ',' + q[1].toFixed(2); }).join(' '); }
// c: 0 pushing, 1 cheering. phase: walk cycle. breath: 0…1 idle bob. hop: px up.
function pose(c, phase, walking, breath, hop) {
var hip = [mix(10.5, 11, c), mix(21, 21.5, c) - hop];
var sh = [mix(15.5, 11.5, c), mix(11.5, 11, c) + breath * 0.6 - hop];
var hd = [mix(18.6, 12, c), mix(6.6, 5.2, c) + breath * 0.6 - hop];
torso.setAttribute('x1', hip[0]); torso.setAttribute('y1', hip[1]);
torso.setAttribute('x2', sh[0]); torso.setAttribute('y2', sh[1]);
head.setAttribute('cx', hd[0].toFixed(2)); head.setAttribute('cy', hd[1].toFixed(2));
function leg(el2, off) {
var s = Math.sin(phase + off) * walking;
var a = mix(-0.38, off ? 0.18 : -0.18, c) + 0.5 * s; // angle from straight down; negative is behind
var lift = Math.max(0, Math.cos(phase + off)) * 1.6 * walking;
var foot = [hip[0] + Math.sin(a) * 12.5, Math.min(33.6, hip[1] + Math.cos(a) * 12.5 - lift)];
var knee = [(hip[0] + foot[0]) / 2 + mix(1.8, 0.4, c), (hip[1] + foot[1]) / 2];
el2.setAttribute('points', pts([hip, knee, foot]));
}
leg(legB, Math.PI);
leg(legF, 0);
// Pushing: hands flat on the cap. Cheering: arms up in a V.
armB.setAttribute('points', pts([sh, [mix(20, 7, c), mix(14, 6, c) - hop], [mix(23.6, 4.5, c), mix(15.5, 0.5, c) - hop]]));
armF.setAttribute('points', pts([sh, [mix(20.5, 16, c), mix(12.5, 6, c) - hop], [mix(24, 18.5, c), mix(12.8, 0.5, c) - hop]]));
}
if (reduced()) {
setP(meter ? clampV(+d.value) : 62);
pose(meter && clampV(+d.value) >= 100 ? 1 : 0, 0, 0, 0, 0);
if (meter) {
new MutationObserver(function () {
var v = clampV(+d.value);
setP(v);
pose(v >= 100 ? 1 : 0, 0, 0, 0, 0);
}).observe(el, { attributes: true, attributeFilter: ['data-value'] });
}
return null;
}
var duration = Math.max(1000, +d.duration || DEFAULTS.duration);
var state = 'push', st = 0, p = 0, segs = plan(duration), seg = 0, segT = 0;
var phase = 0, walking = 0, c = 0, clock = 0, travelEm = 10;
// How far the figure walks from 0 to 100%, in em, so its stride matches the ground.
new ResizeObserver(function () {
var fs = parseFloat(getComputedStyle(el).fontSize) || 16;
travelEm = Math.max(1, scene.clientWidth / fs - FW - CW);
}).observe(scene);
setP(0);
pose(0, 0, 0, 0, 0);
function step(dt) {
st += dt; clock += dt;
var before = p;
if (state === 'push') {
if (meter) {
p += (clampV(+d.value) - p) * Math.min(1, dt / 300);
if (clampV(+d.value) >= 100 && p > 99.6) p = 100;
} else {
var s = segs[seg];
segT += dt;
if (segT < s.ms) p = s.from + (s.to - s.from) * inOut(segT / s.ms);
else {
p = s.to;
if (segT >= s.ms + s.pause && seg < segs.length - 1) { seg++; segT = 0; }
}
}
if (p >= 100) { p = 100; state = 'cheer'; st = 0; }
c = Math.max(0, c - dt / 200);
} else if (state === 'cheer') {
c = Math.min(1, c + dt / 180);
if (meter && clampV(+d.value) < 100) { state = 'push'; st = 0; }
else if (!meter && st > 1700) { state = 'reset'; st = 0; }
} else if (state === 'reset') {
var u = Math.min(1, st / 500);
scene.style.opacity = u < 0.5 ? (1 - u * 2).toFixed(2) : ((u - 0.5) * 2).toFixed(2);
if (u >= 0.5 && p > 0) { p = 0; c = 0; }
if (u >= 1) { scene.style.opacity = ''; state = 'push'; st = 0; segs = plan(duration); seg = 0; segT = 0; }
}
setP(p);
// Legs follow the ground covered, so feet don't skate; they settle when it stalls.
var moved = Math.abs(p - before);
var target = state === 'push' && moved > 0.004 ? 1 : 0;
walking += (target - walking) * Math.min(1, dt / 120);
phase += Math.max(moved / 100 * travelEm * 5.5, walking * dt * 0.004);
var breath = (1 - walking) * (1 - c) * (0.5 + 0.5 * Math.sin(clock / 260));
var hop = state === 'cheer' && st < 1200 ? Math.abs(Math.sin(st / 1200 * Math.PI * 2)) * 4 : 0;
pose(c, phase, walking, breath, hop);
}
var last = 0;
return anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
step(dt);
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-push-along-progress]').forEach(function (el) {
if (el._pap) return;
el._pap = true;
var timer = build(el);
if (!timer) return;
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? timer.play() : timer.pause();
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-push-along-progress style="font-size: 24px; color: #6a0dad" aria-label="Uploading"></div>
<!-- as a progress meter: -->
<div data-push-along-progress data-value="0" aria-label="Uploading"></div>
<script>
document.querySelector("[data-push-along-progress]").dataset.value = 40;
</script>