← Loaders

Hamster Wheel

A little hamster runs and runs in its wheel. Its legs scurry, its body bobs and rocks with each stride, and the wheel spins round under its feet. Size it with font-size and colour the wheel with color.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-hamster-wheel> where the loader should appear.

Options

Add any of these attributes to the same element.

data-speedTime for one turn of the wheel; the hamster’s legs keep pace. Default 1100 ms.
data-furThe hamster’s colour. Default #e9a25f.
data-stand="false"Just the wheel, no stand.

It’s 3em across. It only runs while it’s on screen. With reduced motion the hamster stands still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hamster Wheel: a little hamster runs and runs inside its wheel. Its legs
    // scurry, its body bobs, and the wheel spins round under its feet.
    // Sized with font-size (3em across), the wheel coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-hamster-wheel aria-label="Loading"></div>
    // Optional: data-speed (ms for one turn of the wheel, default 1100),
    // data-fur (hamster colour, default #e9a25f), data-stand="false" (no
    // stand, just the wheel).
    var DEFAULTS = { speed: 1100, fur: '#e9a25f' };
    var SIZE = 3; // em

    var css =
      '.hmw{display:inline-block;width:' + SIZE + 'em;height:' + SIZE + 'em;vertical-align:middle}' +
      '.hmw svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.hmw-line{fill:none;stroke:currentColor;stroke-linecap:round}' +
      '.hmw-stand{opacity:.45}' +
      '.hmw-leg{fill:none;stroke-linecap:round;stroke-width:3.2}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    var CY = -6, R = 36; // wheel centre and radius (viewBox units)

    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 build(el) {
      var d = el.dataset;
      var fur = d.fur || DEFAULTS.fur;
      el.classList.add('hmw');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';

      var svg = make(el, 'svg', { viewBox: '-50 -50 100 100', 'aria-hidden': 'true' });
      if (d.stand !== 'false') {
        make(svg, 'path', { class: 'hmw-line hmw-stand', d: 'M0 ' + CY + 'L-24 46M0 ' + CY + 'L24 46M-32 46H32', 'stroke-width': 3.5 });
      }
      // The wheel: two rims joined by rungs, and spokes to the hub.
      var wheel = make(svg, 'g', {});
      make(wheel, 'circle', { class: 'hmw-line', cx: 0, cy: CY, r: R + 3, 'stroke-width': 2.6 });
      make(wheel, 'circle', { class: 'hmw-line', cx: 0, cy: CY, r: R, 'stroke-width': 1.6, opacity: 0.55 });
      var rungs = '', spokes = '';
      for (var i = 0; i < 24; i++) {
        var a = i * Math.PI / 12, c = Math.cos(a), s = Math.sin(a);
        rungs += 'M' + (c * R).toFixed(2) + ' ' + (CY + s * R).toFixed(2) + 'L' + (c * (R + 3)).toFixed(2) + ' ' + (CY + s * (R + 3)).toFixed(2);
        if (i % 4 === 0) spokes += 'M0 ' + CY + 'L' + (c * R).toFixed(2) + ' ' + (CY + s * R).toFixed(2);
      }
      make(wheel, 'path', { class: 'hmw-line', d: rungs, 'stroke-width': 1.4, opacity: 0.8 });
      make(wheel, 'path', { class: 'hmw-line', d: spokes, 'stroke-width': 1.4, opacity: 0.35 });
      make(svg, 'circle', { cx: 0, cy: CY, r: 3.2, fill: 'currentColor' });

      // The hamster, running to the right along the bottom of the wheel.
      var ham = make(svg, 'g', {});
      var feetY = CY + R - 2.5;
      var hips = [[-9, feetY - 5.5, 0.5], [6, feetY - 5.5, 0], [-11, feetY - 5.5, 0.62], [4, feetY - 5.5, 0.12]];
      var legs = hips.map(function (h, i) {
        var leg = make(ham, 'line', { class: 'hmw-leg', x1: h[0], y1: h[1], x2: h[0], y2: h[1] + 5, stroke: fur });
        if (i > 1) leg.setAttribute('opacity', '0.55');
        return { el: leg, x: h[0], y: h[1], off: h[2] };
      });
      // Far legs sit behind the body, near ones in front.
      ham.insertBefore(legs[2].el, ham.firstChild);
      ham.insertBefore(legs[3].el, ham.firstChild);
      var by = feetY - 12;
      var body = make(ham, 'g', {});
      make(body, 'circle', { cx: -15.5, cy: by - 2, r: 2.4, fill: fur });
      make(body, 'ellipse', { cx: -2, cy: by, rx: 14, ry: 9.5, fill: fur });
      make(body, 'ellipse', { cx: 1, cy: by + 4.5, rx: 9, ry: 4.2, fill: '#fff', opacity: 0.55 });
      make(body, 'circle', { cx: 10.5, cy: by - 1.5, r: 7.6, fill: fur });
      make(body, 'circle', { cx: 6.5, cy: by - 8.5, r: 3.2, fill: fur });
      make(body, 'circle', { cx: 6.6, cy: by - 8.4, r: 1.7, fill: '#f9a8d4' });
      make(body, 'circle', { cx: 13.4, cy: by - 3, r: 1.5, fill: '#1f2937' });
      make(body, 'circle', { cx: 13.9, cy: by - 3.5, r: 0.5, fill: '#fff' });
      make(body, 'circle', { cx: 17.8, cy: by - 0.2, r: 1.2, fill: '#f472b6' });
      make(body, 'circle', { cx: 12, cy: by + 2, r: 2, fill: '#f9a8d4', opacity: 0.6 });
      ham.appendChild(legs[0].el);
      ham.appendChild(legs[1].el);

      function draw(ms, speed) {
        wheel.setAttribute('transform', 'rotate(' + ((ms / speed * 360) % 360).toFixed(2) + ' 0 ' + CY + ')');
        // About four strides for every turn of the wheel.
        var stride = ms / (speed / 4);
        legs.forEach(function (l) {
          var a = Math.sin((stride + l.off) * Math.PI * 2) * 0.75;
          l.el.setAttribute('x2', (l.x + Math.sin(a) * 5.5).toFixed(2));
          l.el.setAttribute('y2', (l.y + Math.cos(a) * 5.5).toFixed(2));
        });
        var bob = Math.abs(Math.sin(stride * Math.PI)) * 1.6;
        var tilt = Math.sin(stride * Math.PI * 2) * 2.5;
        body.setAttribute('transform', 'translate(0 ' + (-bob).toFixed(2) + ') rotate(' + tilt.toFixed(2) + ' 0 ' + by + ')');
      }

      var speed = Math.max(300, +d.speed || DEFAULTS.speed);
      draw(speed * 0.1, speed);
      if (reduced()) return null;
      var t = 0, last = 0;
      return anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += last ? Math.min(50, now - last) : 16;
          last = now;
          draw(t, speed);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-hamster-wheel]').forEach(function (el) {
        if (el._hmw) return;
        el._hmw = 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-hamster-wheel style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>