← Loaders

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

  1. Paste the snippet just before </head>.
  2. 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.
  3. 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-valueTurn it into a progress meter filled to this percentage. Change it and the figure pushes the bar there.
data-durationTime to fill the bar when looping. Default 4200 ms.
data-figureThe 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.

1 · Snippet
<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>
2 · Your HTML
<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>