← Numbers

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

  1. Paste the snippet just before </head>.
  2. Add data-footprint-steps="4280" (the starting count) to an empty element. It fills its container’s width; size it with font-size.

Options

Add any of these attributes to the same element.

data-goalShow “of 10,000” and a progress bar that fills as it walks.
data-labelText under the count. Default steps.
data-intervalTime per step. Default 420 ms.
data-trailHow many prints stay before fading. Default 9.
data-colorPrint colour. Default the text colour.
data-bar-colorProgress bar colour. Default #6a0dad.
1 · Snippet
<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>
2 · Your HTML
<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>