← Backgrounds

Shooting Stars

A night sky full of softly twinkling stars, where every so often a shooting star streaks across, trailing a fading tail, and burns out.

Make a wish

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-shooting-stars to a section with a dark background.

Options

Add any of these attributes to the same element.

data-intervalAverage time between shooting stars. Default 1600 ms.
data-colorColour of the streaks. Default white.
data-starsHow many twinkling stars, as a multiplier. Default 1.
data-angleDirection of travel, in degrees below the horizontal. Use a negative angle to fly to the left. Default 28.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion it shows a still starry sky.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Shooting Stars: a night sky of softly twinkling stars, where now and
    // then a shooting star streaks across and burns out. Drawn on a
    // transparent canvas behind your content, and paused when off screen.
    // Usage: <section data-shooting-stars style="background:#0b1026">…</section>
    // Optional: data-interval (average time between shooting stars in ms,
    // default 1600), data-color (streak colour, default white), data-stars
    // (how many stars, as a multiplier, default 1), data-angle (direction of
    // travel in degrees below the horizontal, default 28; negative goes left).
    var DEFAULTS = { interval: 1600, color: '#ffffff', stars: 1, angle: 28 };

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

    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var v = probe.fillStyle;
      if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
      return v.match(/[\d.]+/g).slice(0, 3).map(Number);
    }

    function build(el) {
      var d = el.dataset;
      var interval = +d.interval || DEFAULTS.interval;
      var c = rgb(d.color || DEFAULTS.color);
      var col = c[0] + ',' + c[1] + ',' + c[2];
      var density = d.stars !== undefined ? +d.stars : DEFAULTS.stars;
      var angle = (d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
      var canvas = document.createElement('canvas');
      canvas.className = 'shs-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0, next = 500;
      var stars = [], meteors = [];

      function star() {
        var big = Math.random() < 0.08;
        return {
          x: Math.random(), y: Math.random(),          // fractions, so resizing keeps the sky
          r: big ? R(1.1, 1.7, 2) : R(0.35, 1, 2),
          a: R(0.35, 0.95, 2),
          tw: R(0.6, 2.4, 2), ph: R(0, 6.28, 2),       // twinkle speed and phase
          big: big
        };
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        var n = Math.round(Math.min(500, (w * h) / 1500) * density);
        while (stars.length < n) stars.push(star());
        stars.length = Math.max(0, n);
        if (still) draw();
      }

      // A new shooting star starts somewhere along the top or the side it
      // comes from, and travels part of the way across before burning out.
      function launch() {
        var a = angle + R(-0.12, 0.12, 3);
        var dir = Math.cos(a) >= 0 ? 1 : -1;
        var x, y;
        if (Math.random() < 0.7) { x = R(-0.1, 0.9, 3) * w; y = R(-0.05, 0.25, 3) * h; }
        else { x = dir > 0 ? -10 : w + 10; y = R(0, 0.5, 3) * h; }
        if (dir < 0 && x < w * 0.2) x = w - x;
        var diag = Math.hypot(w, h);
        meteors.push({
          x: x, y: y, dx: Math.cos(a), dy: Math.abs(Math.sin(a)),
          v: R(0.9, 1.4, 2) * Math.max(500, diag),      // px per second
          len: R(0.12, 0.22, 3) * Math.max(500, diag),   // tail length
          life: 0, dur: R(0.55, 1.0, 2),                 // seconds
          width: R(1.2, 2, 2)
        });
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        // The stars.
        ctx.fillStyle = '#fff';
        for (var i = 0; i < stars.length; i++) {
          var s = stars[i];
          var tw = 0.55 + 0.45 * Math.sin(t * s.tw + s.ph);
          ctx.globalAlpha = s.a * tw;
          var x = s.x * w, y = s.y * h;
          if (s.big) {
            // The brightest get a soft halo.
            var g = ctx.createRadialGradient(x, y, 0, x, y, s.r * 5);
            g.addColorStop(0, 'rgba(255,255,255,.55)');
            g.addColorStop(1, 'rgba(255,255,255,0)');
            ctx.fillStyle = g;
            ctx.fillRect(x - s.r * 5, y - s.r * 5, s.r * 10, s.r * 10);
            ctx.fillStyle = '#fff';
          }
          ctx.beginPath();
          ctx.arc(x, y, s.r, 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.globalAlpha = 1;
        // The shooting stars: a bright head with a tail that thins away.
        ctx.lineCap = 'round';
        for (var j = 0; j < meteors.length; j++) {
          var m = meteors[j], p = m.life / m.dur;
          var fade = Math.min(1, p * 6) * (1 - p * p);
          var tail = m.len * Math.min(1, p * 3);
          var tx = m.x - m.dx * tail, ty = m.y - m.dy * tail;
          var lg = ctx.createLinearGradient(m.x, m.y, tx, ty);
          lg.addColorStop(0, 'rgba(' + col + ',' + fade + ')');
          lg.addColorStop(0.15, 'rgba(' + col + ',' + fade * 0.5 + ')');
          lg.addColorStop(1, 'rgba(' + col + ',0)');
          ctx.strokeStyle = lg;
          ctx.lineWidth = m.width;
          ctx.beginPath();
          ctx.moveTo(tx, ty);
          ctx.lineTo(m.x, m.y);
          ctx.stroke();
          var hg = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, 7);
          hg.addColorStop(0, 'rgba(255,255,255,' + fade + ')');
          hg.addColorStop(0.3, 'rgba(' + col + ',' + fade * 0.5 + ')');
          hg.addColorStop(1, 'rgba(' + col + ',0)');
          ctx.fillStyle = hg;
          ctx.fillRect(m.x - 7, m.y - 7, 14, 14);
        }
      }

      function advance(dt) {
        var s = dt / 1000;
        t += s;
        next -= dt;
        if (next <= 0) {
          launch();
          next = interval * R(0.4, 1.6, 2);
        }
        for (var i = meteors.length - 1; i >= 0; i--) {
          var m = meteors[i];
          m.life += s;
          m.x += m.dx * m.v * s;
          m.y += m.dy * m.v * s;
          if (m.life >= m.dur) meteors.splice(i, 1);
        }
      }

      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (still) { draw(); 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;
          advance(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-shooting-stars]').forEach(function (el) {
        if (el._shs) return;
        el._shs = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-shooting-stars style="background: radial-gradient(ellipse at 50% 120%, #312e81, #0b1026 60%); color: #fff">
  <h1>Make a wish</h1>
</section>