← Generative art

Spiral Galaxy

Thousands of stars wheel round a bright core, the inner ones faster, and two spiral arms stand out where their orbits crowd together. Each star follows its own oval, each oval is turned a little further than the one inside it, and the arms are simply where the ovals bunch up, which is how astronomers think real spiral arms form.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-spiral-galaxy to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-starsHow many stars. Default scaled to the box’s area, 1500 to 9000.
data-twistHow tightly the arms wind. Default 3.2.
data-tiltHow far the disc leans back, from 0 (face-on) to 0.8. Default 0.45.
data-colorsCore, arm and rim colours. Default #fff7d6,#c4b5fd,#7dd3fc.
data-speedSpeed multiplier. Default 1.

The stars are drawn with additive blending, so where they crowd together they glow brighter. It’s paused when off screen, and with reduced motion it shows a still galaxy.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spiral Galaxy: thousands of stars wheel round a bright core, and two
    // spiral arms appear where their orbits crowd together. Each star follows
    // its own oval orbit, and each oval is turned a little further than the
    // one inside it, which is how real spiral arms are thought to form. The
    // galaxy is tilted towards you and turns slowly.
    // Usage: <div data-spiral-galaxy style="height:360px;background:#07040f"></div>
    // Optional: data-stars (default scaled to the box's area, 1500 to 9000),
    // data-twist (how tightly the arms wind, default 3.2), data-tilt (how far
    // it leans back, 0 flat-on to 0.8, default 0.45), data-colors (core, arm
    // and outer colours, default "#fff7d6,#c4b5fd,#7dd3fc"), data-speed
    // (speed multiplier, default 1).
    var DEFAULTS = { twist: 3.2, tilt: 0.45, colors: '#fff7d6,#c4b5fd,#7dd3fc', speed: 1 };

    var css =
      '[data-spiral-galaxy]{position:relative;isolation:isolate;overflow:hidden}' +
      '.sgx-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function rgb(hex) {
      var c = hex.replace('#', '');
      if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
      var n = parseInt(c, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }
    function mix(a, b, t) {
      return 'rgb(' + Math.round(a[0] + (b[0] - a[0]) * t) + ',' + Math.round(a[1] + (b[1] - a[1]) * t) + ',' +
        Math.round(a[2] + (b[2] - a[2]) * t) + ')';
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'sgx-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var twist = d.twist !== undefined ? +d.twist : DEFAULTS.twist;
      var tilt = d.tilt !== undefined ? Math.max(0, Math.min(0.8, +d.tilt)) : DEFAULTS.tilt;
      var cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return rgb(c.trim()); });
      while (cols.length < 3) cols.push(cols[cols.length - 1]);
      var speed = +d.speed || DEFAULTS.speed;
      var w = 0, h = 0, dpr = 1, R = 1, t = 0, spin = 0;
      var BUCKETS = 6, palette = [], stars = [];

      // Colour buckets from core to rim, so stars can be drawn in a few batches.
      for (var b = 0; b < BUCKETS; b++) {
        var f = b / (BUCKETS - 1);
        palette.push(f < 0.5 ? mix(cols[0], cols[1], f * 2) : mix(cols[1], cols[2], (f - 0.5) * 2));
      }

      function makeStars() {
        var n = +d.stars || Math.round(Math.max(1500, Math.min(9000, w * h / 26)));
        stars = [];
        for (var i = 0; i < n; i++) {
          // More stars near the middle, thinning out towards the rim.
          var r = 0.06 + 0.94 * Math.pow(Math.random(), 0.85);
          if (Math.random() < 0.18) r = Math.pow(Math.random(), 1.5) * 0.16;
          stars.push({
            r: r,
            e: 0.5 + Math.random() * 0.06,       // how squashed the oval is
            a: Math.random() * Math.PI * 2,      // where on its oval it is
            j: (Math.random() - 0.5) * 0.035,    // a little scatter
            k: (Math.random() - 0.5) * 0.035,
            s: Math.random() < 0.06 ? 2 : 1,     // the odd bright star
            b: Math.min(BUCKETS - 1, Math.floor(Math.min(1, r * 1.3 + Math.random() * 0.15) * BUCKETS))
          });
        }
        stars.sort(function (p, q) { return p.b - q.b; });
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        // Big enough to fill the box, allowing for the tilt squashing it.
        R = Math.min(w * 0.5, h * 0.5 / (1 - tilt * 0.75)) * 1.12;
        makeStars();
        return true;
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var cx = w / 2, cy = h / 2, sy = 1 - tilt * 0.75;
        var lean = -0.35;
        var cl = Math.cos(lean), sl = Math.sin(lean);
        // A faint haze across the disc.
        ctx.save();
        ctx.translate(cx, cy);
        ctx.rotate(lean);
        ctx.scale(1, sy);
        var haze = ctx.createRadialGradient(0, 0, 0, 0, 0, R);
        haze.addColorStop(0, 'rgba(196,181,253,.22)');
        haze.addColorStop(0.4, 'rgba(129,140,248,.08)');
        haze.addColorStop(1, 'rgba(99,102,241,0)');
        ctx.fillStyle = haze;
        ctx.beginPath();
        ctx.arc(0, 0, R, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();

        ctx.globalCompositeOperation = 'lighter';
        var bucket = -1;
        for (var i = 0; i < stars.length; i++) {
          var s = stars[i];
          if (s.b !== bucket) { bucket = s.b; ctx.fillStyle = palette[bucket]; ctx.globalAlpha = 0.85 - bucket * 0.07; }
          // Inner stars go round faster.
          var ang = s.a + t / (0.18 + s.r) * 0.35;
          var rot = s.r * twist + spin;
          var ex = Math.cos(ang) * s.r, ey = Math.sin(ang) * s.r * s.e;
          var x = ex * Math.cos(rot) - ey * Math.sin(rot) + s.j;
          var y = ex * Math.sin(rot) + ey * Math.cos(rot) + s.k;
          // Tilt the disc and lean it a little.
          x *= R; y *= R * sy;
          var px = cx + x * cl - y * sl, py = cy + x * sl + y * cl;
          ctx.fillRect(px, py, s.s * 0.9, s.s * 0.9);
        }
        ctx.globalAlpha = 1;
        // The bright core.
        var cr = R * 0.28;
        var core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
        core.addColorStop(0, 'rgba(255,255,255,.95)');
        core.addColorStop(0.15, 'rgba(255,240,200,.6)');
        core.addColorStop(0.5, 'rgba(250,204,150,.15)');
        core.addColorStop(1, 'rgba(250,204,150,0)');
        ctx.fillStyle = core;
        ctx.fillRect(cx - cr, cy - cr, cr * 2, cr * 2);
        ctx.globalCompositeOperation = 'source-over';
      }

      resize();
      t = 4;
      draw();
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
      if (reduced()) return;

      var last = 0;
      var timer = 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;
          t += dt / 1000 * speed;
          spin += dt / 1000 * speed * 0.06;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-spiral-galaxy]').forEach(function (el) {
        if (el._sgx) return;
        el._sgx = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-spiral-galaxy style="height: 360px; background: #07040f"></div>

<!-- face-on, tightly wound: -->
<div data-spiral-galaxy data-tilt="0" data-twist="5" style="height: 360px; background: #07040f"></div>