← Backgrounds

Bokeh Lights

Big, blurry circles of light drift slowly upwards, each glowing brighter and dimmer on its own rhythm, like out-of-focus city lights behind a portrait. Nearer ones are larger and softer.

City lights

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours, any number. Default pink, amber, violet, sky blue and orange.
data-densityHow many lights, as a multiplier. Default 1.
data-sizeSize multiplier. Default 1.
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 () {
    // Bokeh Lights: big, blurry circles of light drift slowly upwards and
    // glow brighter and dimmer, like out-of-focus city lights behind a
    // portrait. Drawn on a transparent canvas behind your content, and paused
    // when off screen.
    // Usage: <section data-bokeh-lights style="background:#0d0716">…</section>
    // Optional: data-colors (comma-separated, default pink, amber, violet, sky
    // blue and orange), data-density (how many, as a multiplier, default 1),
    // data-size (size multiplier, default 1), data-speed (default 1).
    var DEFAULTS = { colors: ['#f472b6', '#fbbf24', '#a78bfa', '#60a5fa', '#fb923c'], density: 1, size: 1, speed: 1 };

    var css =
      '[data-bokeh-lights]{position:relative;isolation:isolate;overflow:hidden}' +
      '.bkh-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);

    // A pre-drawn disc of light. Crisp ones have a faint bright rim, like a
    // real lens; soft ones are further out of focus.
    function sprite(color, soft) {
      var S = 128, c = document.createElement('canvas');
      c.width = c.height = S;
      var g = c.getContext('2d');
      g.fillStyle = color;
      g.fillRect(0, 0, S, S);
      g.globalCompositeOperation = 'destination-in';
      var m = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
      if (soft) {
        m.addColorStop(0, 'rgba(0,0,0,.6)');
        m.addColorStop(0.5, 'rgba(0,0,0,.42)');
        m.addColorStop(1, 'rgba(0,0,0,0)');
      } else {
        m.addColorStop(0, 'rgba(0,0,0,.5)');
        m.addColorStop(0.72, 'rgba(0,0,0,.58)');
        m.addColorStop(0.86, 'rgba(0,0,0,.78)');
        m.addColorStop(0.94, 'rgba(0,0,0,.35)');
        m.addColorStop(1, 'rgba(0,0,0,0)');
      }
      g.fillStyle = m;
      g.fillRect(0, 0, S, S);
      return c;
    }

    function build(el) {
      var d = el.dataset;
      var colors = d.colors ? d.colors.split(',').map(function (s) { return s.trim(); }) : DEFAULTS.colors;
      var sprites = colors.map(function (c) { return [sprite(c, false), sprite(c, true)]; });
      var density = d.density !== undefined ? +d.density : DEFAULTS.density;
      var size = +d.size || DEFAULTS.size;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var canvas = document.createElement('canvas');
      canvas.className = 'bkh-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, unit = 1;
      var lights = [];

      function light(anywhere) {
        var depth = Math.random();       // 0 = near (big, soft), 1 = far (small, crisp)
        return {
          x: Math.random() * w,
          y: anywhere ? Math.random() * h : h + 80,
          r: (18 + (1 - depth) * 46) * R(0.75, 1.25, 2),
          soft: depth < 0.35,
          img: (Math.random() * sprites.length) | 0,
          v: R(5, 14, 1) * (1.2 - depth * 0.6),      // px per second, upwards
          sway: R(6, 18, 1), swayT: R(0.15, 0.4, 2), ph: R(0, 6.28, 2),
          glow: R(0.4, 1.1, 2), glowPh: R(0, 6.28, 2)
        };
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // Lights are a little smaller in small boxes.
        unit = Math.min(1.2, Math.max(0.6, Math.min(w, h) / 360)) * size;
        var n = Math.round(Math.min(70, Math.max(10, (w * h) / 9000)) * density);
        while (lights.length < n) lights.push(light(true));
        lights.length = Math.max(0, n);
        if (still) draw();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        for (var i = 0; i < lights.length; i++) {
          var p = lights[i];
          var r = p.r * unit;
          var x = p.x + Math.sin(t * p.swayT + p.ph) * p.sway;
          ctx.globalAlpha = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * p.glow + p.glowPh));
          ctx.drawImage(sprites[p.img][p.soft ? 1 : 0], x - r, p.y - r, r * 2, r * 2);
        }
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
      }

      function advance(dt) {
        var s = (dt / 1000) * speed;
        t += s;
        for (var i = 0; i < lights.length; i++) {
          var p = lights[i];
          p.y -= p.v * s;
          if (p.y < -p.r * unit - 20) lights[i] = light(false);
        }
      }

      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-bokeh-lights]').forEach(function (el) {
        if (el._bkh) return;
        el._bkh = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-bokeh-lights style="background: #0d0716; color: #fff">
  <h1>City lights</h1>
</section>