← Loaders

Moon Phase Loader

A moon waxes from new to full and wanes back again, its shadow edge curving across the face just like the real thing. Craters show on the lit side and a soft glow grows round it as it fills. Let it glide, or hop through the eight phases. Size it with font-size and colour it with color.

How to use it

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

Options

Add any of these attributes to the same element.

data-speedTime for a full cycle, new moon to new moon. Default 4000 ms.
data-stepsHold on each of this many phases (8 is the classic set) and hop to the next, instead of gliding.
data-starsAdd a few twinkling stars round the moon.
data-darkColour of the unlit part. Default a faint color.

It’s 2.4em across. It only moves while it’s on screen. With reduced motion the moon sits still as a waxing gibbous.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Moon Phase Loader: a moon waxes from new to full and wanes back again,
    // its shadow edge curving across the face like the real thing. A few
    // craters show on the lit side and a soft glow grows as it fills.
    // Sized with font-size (2.4em across), coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-moon-phase-loader aria-label="Loading"></div>
    // Optional: data-speed (ms for a full cycle, default 4000), data-steps
    // (jump through this many phases, e.g. 8, instead of gliding), data-stars
    // (a few twinkling stars round it), data-dark (colour of the unlit part,
    // default color at low opacity).
    var DEFAULTS = { speed: 4000 };
    var SIZE = 2.4; // em
    var R = 20;     // moon radius in the 50 x 50 view box

    var css =
      '.mpl{display:inline-block;width:' + SIZE + 'em;height:' + SIZE + 'em;vertical-align:middle}' +
      '.mpl svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.mpl-dark{fill:currentColor;opacity:.16}' +
      '.mpl-lit{fill:currentColor}' +
      '.mpl-crater{fill:#000;opacity:.13}' +
      '.mpl-glow{fill:currentColor}' +
      '.mpl-star{fill:currentColor}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;

    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;
    }

    // The lit part of the moon at phase p (0 new, 0.5 full, 1 new again):
    // half the rim on the lit side, closed by an elliptical shadow edge.
    function litPath(p) {
      var right = p < 0.5;                     // waxing: lit on the right
      var crescent = p < 0.25 || p > 0.75;     // less than half lit
      var rx = Math.abs(Math.cos(p * Math.PI * 2)) * R;
      var outer = right ? 1 : 0;
      var term = right ? (crescent ? 0 : 1) : (crescent ? 1 : 0);
      return 'M25,' + (25 - R) +
        'A' + R + ',' + R + ' 0 0 ' + outer + ' 25,' + (25 + R) +
        'A' + rx.toFixed(3) + ',' + R + ' 0 0 ' + term + ' 25,' + (25 - R) + 'Z';
    }

    function build(el) {
      var d = el.dataset;
      var id = 'mpl-' + (++uid);
      var steps = Math.round(+d.steps) || 0;
      el.classList.add('mpl');
      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: '0 0 50 50', 'aria-hidden': 'true' });
      var defs = make(svg, 'defs', {});
      var grad = make(defs, 'radialGradient', { id: id + '-g' });
      make(grad, 'stop', { offset: '55%', 'stop-color': 'currentColor', 'stop-opacity': '.22' });
      make(grad, 'stop', { offset: '100%', 'stop-color': 'currentColor', 'stop-opacity': '0' });
      var clip = make(defs, 'clipPath', { id: id + '-c' });
      var clipPath = make(clip, 'path', {});

      var stars = [];
      if (d.stars !== undefined && d.stars !== 'false') {
        [[4, 8, 1.1], [45, 6, 0.9], [47, 40, 1.2], [6, 44, 0.8], [41, 24, 0.7]].forEach(function (s, i) {
          stars.push(make(svg, 'circle', { class: 'mpl-star', cx: s[0], cy: s[1], r: s[2] }));
          stars[i].style.opacity = '.5';
        });
      }
      var glow = make(svg, 'circle', { class: 'mpl-glow', cx: 25, cy: 25, r: R * 1.32, fill: 'url(#' + id + '-g)' });
      var dark = make(svg, 'circle', { class: 'mpl-dark', cx: 25, cy: 25, r: R });
      if (d.dark) { dark.style.fill = d.dark; dark.style.opacity = '1'; }
      var lit = make(svg, 'path', { class: 'mpl-lit' });
      var craters = make(svg, 'g', { 'clip-path': 'url(#' + id + '-c)' });
      [[18, 17, 4.2], [31, 30, 5.2], [33, 15, 2.4], [17, 32, 2.8], [26, 24, 1.8], [37, 23, 1.6]].forEach(function (c) {
        make(craters, 'circle', { class: 'mpl-crater', cx: c[0], cy: c[1], r: c[2] });
      });
      el.appendChild(svg);

      function draw(p, t) {
        p = (p % 1 + 1) % 1;
        var path = litPath(p);
        lit.setAttribute('d', path);
        clipPath.setAttribute('d', path);
        // Full moon glows; new moon doesn't.
        var full = (1 - Math.cos(p * Math.PI * 2)) / 2;
        glow.style.opacity = (full * full).toFixed(3);
        stars.forEach(function (s, i) {
          s.style.opacity = (0.25 + 0.6 * (0.5 + 0.5 * Math.sin(t / 420 + i * 1.9))).toFixed(3);
        });
      }

      draw(0.3, 0);
      if (reduced()) return null;
      var speed = Math.max(600, +d.speed || DEFAULTS.speed);
      var t = 0.12 * speed, 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;
          var p = t / speed;
          if (steps > 1) {
            // Hold on each phase, then ease quickly to the next.
            var k = p * steps, i = Math.floor(k), f = k - i;
            f = f < 0.7 ? 0 : (f - 0.7) / 0.3;
            f = f < 0.5 ? 2 * f * f : 1 - Math.pow(-2 * f + 2, 2) / 2;
            p = (i + f) / steps;
          }
          draw(p, t);
        },
        onPause: function () { last = 0; },
      });
    }

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

<!-- hopping through the eight phases: -->
<div data-moon-phase-loader data-steps="8" data-speed="6400"></div>