Footprint Steps
Bare footprints walk across the screen, left foot then right, each pressing in and fading away behind the walker, while a step count climbs with every step. Give it a goal and a progress bar fills as it goes.
How to use it
- Paste the snippet just before
</head>. - Add
data-footprint-steps="4280"(the starting count) to an empty element. It fills its container’s width; size it withfont-size.
Options
Add any of these attributes to the same element.
data-goal | Show “of 10,000” and a progress bar that fills as it walks. |
data-label | Text under the count. Default steps. |
data-interval | Time per step. Default 420 ms. |
data-trail | How many prints stay before fading. Default 9. |
data-color | Print colour. Default the text colour. |
data-bar-color | Progress bar colour. Default #6a0dad. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Footprint Steps: bare footprints walk across the screen, left, right,
// left, pressing in and fading behind, while a step count climbs with
// every step. Add a goal for a progress bar that fills as it walks.
// Usage: <div data-footprint-steps="4280"></div> (the starting count)
// Optional: data-goal (shows "of 10,000" and a progress bar), data-label
// (text under the count, default "steps"), data-interval (ms per step,
// default 420), data-trail (how many prints stay before fading, default 9),
// data-color (print colour, default the text colour), data-bar-color
// (progress colour, default #6a0dad).
// Size it with font-size and width: it fills its container's width and the
// prints are 1.7em long.
var DEFAULTS = { interval: 420, trail: 9, label: 'steps', bar: '#6a0dad' };
// A left foot, toes up; the right foot is its mirror image.
var FOOT = '<svg viewBox="0 0 24 44" aria-hidden="true"><circle cx="15.6" cy="5.4" r="3.4"/><circle cx="10.6" cy="4.4" r="2.3"/><circle cx="6.9" cy="6" r="2"/>' +
'<circle cx="4.1" cy="8.8" r="1.75"/><circle cx="2.4" cy="12.4" r="1.5"/>' +
'<path d="M4.6 18.5C4.4 13.6 9.3 11 14 11.6c4.6.6 6.4 4.3 5.8 8.6-.5 3.6-2.6 6.4-3 10-.3 2.6 1 4.8.6 7.6-.4 3.2-3.2 5.2-6.6 5-3.6-.2-5.6-2.7-5.5-6.1.1-3.2 1.2-5.3.8-8.6-.3-3.2-1.4-5.2-1.5-9.6z"/></svg>';
var css =
'.fps{display:flex;flex-direction:column;align-items:center;gap:.5em;width:100%;font-variant-numeric:tabular-nums}' +
'.fps-ground{position:relative;width:100%;height:4.6em;overflow:hidden}' +
'.fps-print{position:absolute;left:0;top:0;width:.95em;height:1.75em;margin:-.875em 0 0 -.475em;color:var(--fps-c,currentColor);opacity:0}' +
'.fps-print>span,.fps-print svg{display:block;width:100%;height:100%;fill:currentColor}' +
'.fps-print.fps-r svg{transform:scaleX(-1)}' +
'.fps-read{display:flex;flex-direction:column;align-items:center;gap:.3em;width:min(14em,100%)}' +
'.fps-num{font-size:2em;font-weight:800;letter-spacing:-.03em;line-height:1}' +
'.fps-label{font-size:.7em;font-weight:700;letter-spacing:.12em;text-transform:uppercase;opacity:.6}' +
'.fps-bar{width:100%;height:.45em;border-radius:1em;background:rgba(127,127,140,.18);overflow:hidden}' +
'.fps-bar i{display:block;height:100%;width:0;border-radius:inherit;background:var(--fps-b)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var F = {
el: el, count: Math.max(0, Math.round(+el.getAttribute('data-footprint-steps') || 0)), goal: +d.goal || 0,
interval: +d.interval || DEFAULTS.interval, trail: Math.max(2, +d.trail || DEFAULTS.trail),
still: reduced(), visible: false, timer: 0, prints: [], dist: 0, foot: 0, fmt: new Intl.NumberFormat('en-GB')
};
el.classList.add('fps');
if (d.color) el.style.setProperty('--fps-c', d.color);
el.style.setProperty('--fps-b', d.barColor || DEFAULTS.bar);
var label = d.label !== undefined ? d.label : DEFAULTS.label;
el.innerHTML = '<div class="fps-ground" aria-hidden="true"></div><div class="fps-read" aria-hidden="true"><span class="fps-num"></span>' +
'<span class="fps-label">' + (F.goal ? 'of ' + F.fmt.format(F.goal) + ' ' : '') + label + '</span>' + (F.goal ? '<span class="fps-bar"><i></i></span>' : '') + '</div>';
F.ground = el.querySelector('.fps-ground');
F.num = el.querySelector('.fps-num');
F.bar = el.querySelector('.fps-bar i');
F.label = label;
el.setAttribute('role', 'img');
readout(F, false);
if (F.still) for (var i = 0; i < F.trail; i++) step(F, false);
return F;
}
function readout(F, animate) {
F.num.textContent = F.fmt.format(F.count);
F.el.setAttribute('aria-label', F.fmt.format(F.count) + (F.goal ? ' of ' + F.fmt.format(F.goal) : '') + ' ' + F.label);
if (F.bar) {
var w = Math.min(100, F.count / F.goal * 100) + '%';
if (animate) anime.animate(F.bar, { width: w, duration: 300, ease: 'out(2)' }); else F.bar.style.width = w;
}
}
// Lays the next print along a gently winding path, alternating feet.
function step(F, animate) {
var em = parseFloat(getComputedStyle(F.ground).fontSize) || 16, w = F.ground.clientWidth, h = F.ground.clientHeight;
var stride = em * 1.25, side = em * 0.42, margin = em * 1.2;
if (F.dist === 0 || F.dist > w + margin) { F.dist = -margin; F.phase = Math.random() * Math.PI * 2; }
F.dist += stride;
var x = F.dist, k = 2 * Math.PI / Math.max(w, em * 10), wave = Math.sin(x * k + F.phase) * h * 0.14;
var slope = Math.atan(Math.cos(x * k + F.phase) * h * 0.14 * k), right = F.foot++ % 2 === 1;
var y = h / 2 + wave + (right ? side : -side) * Math.cos(slope);
var deg = 90 + slope * 180 / Math.PI + (right ? 6 : -6);
var p = document.createElement('span');
p.className = 'fps-print' + (right ? ' fps-r' : '');
p.innerHTML = '<span style="transform:rotate(' + deg.toFixed(1) + 'deg)">' + FOOT + '</span>';
p.style.left = (x - (right ? side : -side) * Math.sin(slope)).toFixed(1) + 'px';
p.style.top = y.toFixed(1) + 'px';
F.ground.appendChild(p);
F.prints.push(p);
if (animate) anime.animate(p, { opacity: [0, 0.85], scale: [1.35, 1], duration: 260, ease: 'out(3)' });
else p.style.opacity = 0.85 * F.prints.length / F.trail;
// Older prints fade away behind the walker.
if (F.prints.length > F.trail) {
var old = F.prints.shift();
if (animate) anime.animate(old, { opacity: 0, duration: 700, ease: 'out(2)', onComplete: function () { old.remove(); } });
else old.remove();
}
}
function walk(F) {
if (!F.visible) return;
step(F, true);
F.count++;
readout(F, true);
anime.animate(F.num, { y: ['-.08em', '0em'], duration: 220, ease: 'out(2)' });
F.timer = setTimeout(function () { walk(F); }, F.interval * (0.9 + Math.random() * 0.2));
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var F = e.target._fps;
F.visible = e.isIntersecting;
clearTimeout(F.timer);
if (F.visible) F.timer = setTimeout(function () { walk(F); }, 300);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-footprint-steps]').forEach(function (el) {
if (el._fps) return;
var F = (el._fps = setup(el));
if (!F.still) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-footprint-steps="4280" data-goal="10000"></div>
<!-- a long trail, slower: -->
<div data-footprint-steps="0" data-trail="16" data-interval="600" data-label="steps today"></div>