← Backgrounds

Light Rays

Soft beams of light fan down from just above the top of a section, slowly swaying and breathing, with specks of dust catching the light as they drift through. Like sunlight through a high window, or through water.

Into the light

How to use it

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

Options

Add any of these attributes to the same element.

data-colorColour of the light. Default warm white #fff4d6.
data-countNumber of beams. Default 7.
data-originWhere the light comes from across the top, from 0 (left) to 1 (right). Default 0.5.
data-speedSpeed multiplier. Default 1.
data-dust="false"No floating dust.

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

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Light Rays: soft beams of light fan down from above the top of a
    // section, slowly swaying and breathing, with specks of dust drifting
    // through them. Drawn on a transparent canvas behind your content, and
    // paused when off screen.
    // Usage: <section data-light-rays style="background:#0f2a3d">…</section>
    // Optional: data-color (default warm white #fff4d6), data-count (number of
    // beams, default 7), data-origin (where the light comes from across the
    // top, 0 = left, 1 = right, default 0.5), data-speed (default 1),
    // data-dust="false" (no floating dust).
    var DEFAULTS = { color: '#fff4d6', count: 7, origin: 0.5, speed: 1 };

    var css =
      '[data-light-rays]{position:relative;isolation:isolate;overflow:hidden}' +
      '.lry-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 c = rgb(d.color || DEFAULTS.color);
      var col = c[0] + ',' + c[1] + ',' + c[2];
      var count = Math.max(1, +d.count || DEFAULTS.count);
      var origin = d.origin !== undefined ? +d.origin : DEFAULTS.origin;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var dusty = d.dust !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'lry-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, ox = 0, oy = 0, spread = 0.6;
      var beams = [], dust = [];

      // Each beam has a place in the fan, a width, a brightness and its own
      // slow sway and pulse.
      for (var i = 0; i < count; i++) {
        beams.push({
          pos: count === 1 ? 0 : (i / (count - 1)) * 2 - 1 + R(-0.4, 0.4, 2) / count,
          width: R(0.025, 0.07, 3),
          power: R(0.22, 0.5, 2),
          sway: R(0.15, 0.35, 2), swayPhase: R(0, 6.28, 2),
          pulse: R(0.3, 0.7, 2), pulsePhase: 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;
        // The light sits above the top edge; the fan is wide enough to reach
        // both bottom corners.
        ox = w * origin;
        oy = -h * 0.3;
        spread = Math.atan(Math.max(ox, w - ox) / (h - oy)) * 0.9;
        var n = Math.round(Math.min(90, Math.max(14, (w * h) / 6000)));
        while (dust.length < n) dust.push({ x: Math.random() * w, y: Math.random() * h, r: R(0.5, 1.6, 2), v: R(4, 12, 1), ph: R(0, 6.28, 2) });
        dust.length = n;
        if (still) draw();
      }

      // Brightness of the light at a given angle from the origin.
      function light(a) {
        var sum = 0;
        for (var i = 0; i < beams.length; i++) {
          var b = beams[i];
          var center = Math.PI / 2 + b.pos * spread + Math.sin(t * b.sway + b.swayPhase) * 0.05;
          var k = (a - center) / b.width;
          var p = b.power * (0.6 + 0.4 * Math.sin(t * b.pulse + b.pulsePhase));
          sum += p * Math.exp(-k * k);
        }
        return Math.min(1, sum);
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.clearRect(0, 0, w, h);
        var lo = Math.PI / 2 - spread - 0.25, hi = Math.PI / 2 + spread + 0.25;
        if (ctx.createConicGradient) {
          // One angular gradient holds every beam, with soft edges for free.
          var g = ctx.createConicGradient(0, ox, oy), S = 160;
          for (var k = 0; k <= S; k++) {
            var a = lo + ((hi - lo) * k) / S;
            g.addColorStop(a / (Math.PI * 2), 'rgba(' + col + ',' + light(a).toFixed(3) + ')');
          }
          ctx.fillStyle = g;
          ctx.fillRect(0, 0, w, h);
        } else {
          // Older browsers: plain wedges.
          beams.forEach(function (b) {
            var center = Math.PI / 2 + b.pos * spread, L = h * 3;
            ctx.fillStyle = 'rgba(' + col + ',' + (b.power * 0.5) + ')';
            ctx.beginPath();
            ctx.moveTo(ox, oy);
            ctx.lineTo(ox + Math.cos(center - b.width) * L, oy + Math.sin(center - b.width) * L);
            ctx.lineTo(ox + Math.cos(center + b.width) * L, oy + Math.sin(center + b.width) * L);
            ctx.fill();
          });
        }
        // The beams fade out as they travel down.
        ctx.globalCompositeOperation = 'destination-in';
        var reach = Math.hypot(Math.max(ox, w - ox), h - oy) * 1.05;
        var f = ctx.createRadialGradient(ox, oy, 0, ox, oy, reach);
        f.addColorStop(0, 'rgba(0,0,0,1)');
        f.addColorStop(0.35, 'rgba(0,0,0,.85)');
        f.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.fillStyle = f;
        ctx.fillRect(0, 0, w, h);
        // A glow where the light comes in.
        ctx.globalCompositeOperation = 'lighter';
        var top = ctx.createRadialGradient(ox, 0, 0, ox, 0, Math.max(w, h) * 0.45);
        top.addColorStop(0, 'rgba(' + col + ',.35)');
        top.addColorStop(1, 'rgba(' + col + ',0)');
        ctx.fillStyle = top;
        ctx.fillRect(0, 0, w, h);
        // Dust shows up where the beams catch it.
        if (dusty) {
          ctx.fillStyle = 'rgb(' + col + ')';
          for (var i = 0; i < dust.length; i++) {
            var p = dust[i];
            var lit = light(Math.atan2(p.y - oy, p.x - ox));
            ctx.globalAlpha = Math.min(1, 0.08 + lit * 1.4) * (0.6 + 0.4 * Math.sin(t * 1.3 + p.ph));
            ctx.beginPath();
            ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
            ctx.fill();
          }
          ctx.globalAlpha = 1;
        }
        ctx.globalCompositeOperation = 'source-over';
      }

      function advance(dt) {
        t += (dt / 1000) * speed;
        for (var i = 0; i < dust.length; i++) {
          var p = dust[i];
          p.y -= (p.v * dt * speed) / 1000 * 0.4;
          p.x += Math.sin(t * 0.5 + p.ph) * 0.15 * speed;
          if (p.y < -4) { p.y = h + 4; p.x = Math.random() * w; }
          if (p.x < -4) p.x = w + 4; else if (p.x > w + 4) p.x = -4;
        }
      }

      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      t = 3;
      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-light-rays]').forEach(function (el) {
        if (el._lry) return;
        el._lry = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-light-rays style="background: linear-gradient(#123247, #071521); color: #fff">
  <h1>Into the light</h1>
</section>