← Backgrounds

Stage Spotlights

Coloured spotlights rise from the foot of a section and sweep back and forth through the haze like the lights at a concert, their beams crossing and blending where they meet.

Tonight only

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours, one per light and repeated. Default violet, pink, cyan and amber.
data-countHow many lights. Default one per 190 px of width, from 3 to 7.
data-widthHow wide each beam spreads, in degrees. Default 9.
data-speedSpeed multiplier. Default 1.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the lights hold still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stage Spotlights: coloured spotlights rise from the foot of a section
    // and sweep back and forth through the haze like the lights at a concert,
    // their beams crossing and blending where they meet. Drawn on a
    // transparent canvas behind your content, and paused when off screen.
    // Usage: <section data-stage-spotlights style="background:#07050d">…</section>
    // Optional: data-colors (comma-separated, one per light and repeated,
    // default violet, pink, cyan and amber), data-count (how many lights,
    // default one per 190 px of width, 3 to 7), data-speed (default 1),
    // data-width (beam spread in degrees, default 9).
    var DEFAULTS = { colors: '#a855f7,#ec4899,#22d3ee,#fbbf24', speed: 1, width: 9 };

    var css =
      '[data-stage-spotlights]{position:relative;isolation:isolate;overflow:hidden}' +
      '.ssl-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 TAU = Math.PI * 2;
    // [spread, opacity] of each nested cone, outermost first.
    var CONES = [[1.7, 0.05], [1.3, 0.07], [0.95, 0.09], [0.62, 0.12], [0.3, 0.2]];
    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c.trim();
      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 cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return rgb(c).join(','); });
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var spread = ((+d.width || DEFAULTS.width) * Math.PI) / 360; // half-angle
      var canvas = document.createElement('canvas');
      canvas.className = 'ssl-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, lights = [], t = 3;
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      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 = +d.count || Math.max(3, Math.min(7, Math.round(w / 190)));
        if (lights.length !== n) {
          lights = [];
          for (var i = 0; i < n; i++) {
            // Lights lean inwards from the sides, so the beams cross mid-stage.
            var f = n > 1 ? i / (n - 1) : 0.5;
            lights.push({
              f: f,
              lean: (0.5 - f) * 0.7,
              amp: R(28, 46) / 100,
              w1: R(35, 60) / 100,
              w2: R(15, 30) / 100,
              ph: R(0, 628) / 100,
              col: cols[i % cols.length]
            });
          }
        }
        draw();
      }

      function beam(x, y, a, len, half, col, alpha) {
        var ex = x + Math.cos(a) * len, ey = y + Math.sin(a) * len;
        var px = -Math.sin(a), py = Math.cos(a), far = Math.tan(half) * len, near = 3;
        var g = ctx.createLinearGradient(x, y, ex, ey);
        g.addColorStop(0, 'rgba(' + col + ',' + alpha + ')');
        g.addColorStop(0.35, 'rgba(' + col + ',' + (alpha * 0.45).toFixed(3) + ')');
        g.addColorStop(1, 'rgba(' + col + ',0)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.moveTo(x + px * near, y + py * near);
        ctx.lineTo(ex + px * far, ey + py * far);
        ctx.lineTo(ex - px * far, ey - py * far);
        ctx.lineTo(x - px * near, y - py * near);
        ctx.closePath();
        ctx.fill();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        var len = Math.hypot(w, h) * 1.1;
        var margin = Math.min(60, w * 0.08);
        for (var i = 0; i < lights.length; i++) {
          var L = lights[i];
          var x = margin + (w - margin * 2) * L.f, y = h + 6;
          var swing = L.amp * (0.7 * Math.sin(t * L.w1 * speed + L.ph) + 0.3 * Math.sin(t * L.w2 * speed * 2.3 + L.ph * 1.7));
          var a = -Math.PI / 2 + L.lean + swing;
          // Nested cones give the beam a soft edge and a brighter core.
          for (var k = 0; k < CONES.length; k++) beam(x, y, a, len * (k > 3 ? 0.8 : 1), spread * CONES[k][0], L.col, CONES[k][1]);
          // The lamp itself glows at the foot of the stage.
          var g = ctx.createRadialGradient(x, h, 0, x, h, 46);
          g.addColorStop(0, 'rgba(255,255,255,0.75)');
          g.addColorStop(0.15, 'rgba(' + L.col + ',0.6)');
          g.addColorStop(1, 'rgba(' + L.col + ',0)');
          ctx.fillStyle = g;
          ctx.beginPath();
          ctx.arc(x, h, 46, 0, TAU);
          ctx.fill();
        }
        ctx.globalCompositeOperation = 'source-over';
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (still) return;

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += (last ? Math.min(50, now - last) : 16) / 1000;
          last = now;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-stage-spotlights]').forEach(function (el) {
        if (el._ssl) return;
        el._ssl = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-stage-spotlights style="background: #07050d; color: #fff">
  <h1>Tonight only</h1>
</section>