Copied!
Background

Soft light rays from above

Soft god-rays fan down from a source at the top of the frame, sway gently, and follow the pointer sideways.

← Library

Element · Vanilla JS + Canvas 2D + SVGUpdated November 6, 2026

BackgroundElementVanilla JS + Canvas 2D + SVG

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Soft light rays from above" for my site using the Enrichr Library element at https://enrichr.dev/elements/light-rays.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/light-rays.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- accent: #e6ff55
- rays: 12
- intensity: 0.6
- sway: 0.6
- spread: 90

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/light-rays.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-lrs">
  <div class="ef-lrs__wrap">
    <canvas class="ef-lrs__cv" aria-hidden="true"></canvas>
    <svg class="ef-lrs__art" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
      <defs>
        <symbol id="ef-lrs-pz1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol>
        <symbol id="ef-lrs-pz4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol>
        <symbol id="ef-lrs-pz8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol>
      </defs>
      <use class="ef-lrs__pz ef-lrs__pz--a" href="#ef-lrs-pz1" x="40" y="190" width="70" height="70"/>
      <use class="ef-lrs__pz ef-lrs__pz--b" href="#ef-lrs-pz4" x="262" y="40" width="96" height="62"/>
      <use class="ef-lrs__pz ef-lrs__pz--c" href="#ef-lrs-pz8" x="300" y="170" width="52" height="82"/>
    </svg>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-lrs { width: 100%; }
.ef-lrs__wrap { position: relative; box-sizing: border-box; width: 100%; min-height: 18rem; aspect-ratio: 16 / 10; overflow: hidden; border-radius: 1rem; background: #0c1e18; isolation: isolate; }
.ef-lrs__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-lrs__art { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
.ef-lrs__pz { color: #fbf8f1; opacity: 0.9; transform-box: fill-box; transform-origin: 50% 50%; animation: ef-lrs-float 7s ease-in-out infinite; filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.45)); }
.ef-lrs__pz--a { color: var(--ef-acc, #e6ff55); animation-duration: 6s; }
.ef-lrs__pz--b { color: #fbf8f1; animation-duration: 8s; animation-delay: -2s; opacity: 0.8; }
.ef-lrs__pz--c { color: #2f7d5b; animation-duration: 7s; animation-delay: -4s; }
@keyframes ef-lrs-float { 0%, 100% { transform: translateY(0) rotate(-6deg); } 50% { transform: translateY(-9px) rotate(5deg); } }
@media (prefers-reduced-motion: reduce) { .ef-lrs__pz { animation: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Light rays: soft god-rays fan down from a source above the frame. Each ray is a tapered additive gradient wedge on a 2D canvas that
// sways gently; the pointer moves the source sideways and tilts the fan. Original rebuild from the general idea.
// Reduced motion draws one still frame. Pauses off-screen. mount(root, { background, accent, rays (3-24), intensity (0.1-1), sway (0-1), spread (30-140 deg) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-lrs__wrap");
  var cv = root.querySelector(".ef-lrs__cv");
  var ctx = cv.getContext("2d");
  var opts = {}, W = 0, H = 0, dpr = 1, rays = [], raf = 0, visible = true, t0 = performance.now(), tNow = 0, offs = [];
  var src = { x: 0.5, y: 0 }, target = { x: 0.5, y: 0 };

  function rgba(hex, a) {
    var h = hex.replace("#", "");
    if (h.length === 3) h = h.replace(/./g, "$&$&");
    var n = parseInt(h, 16);
    return "rgba(" + (n >> 16 & 255) + "," + (n >> 8 & 255) + "," + (n & 255) + "," + a.toFixed(3) + ")";
  }
  function build() {
    rays = [];
    for (var i = 0; i < opts.rays; i++) {
      var u = opts.rays === 1 ? 0.5 : i / (opts.rays - 1);
      rays.push({ u: u + (Math.random() - 0.5) * 0.06, w: 0.012 + Math.random() * 0.05, ph: Math.random() * 6.28, sp: 0.25 + Math.random() * 0.5, k: Math.random() < 0.3 ? 1 : 0, a: 0.5 + Math.random() * 0.5 });
    }
  }
  function size() {
    var r = wrap.getBoundingClientRect();
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = Math.max(1, Math.round(r.width)); H = Math.max(1, Math.round(r.height));
    cv.width = W * dpr; cv.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    draw();
  }
  function draw() {
    ctx.globalCompositeOperation = "source-over";
    ctx.fillStyle = opts.background; ctx.fillRect(0, 0, W, H);
    var sx = src.x * W, sy = -H * 0.08;
    var L = Math.hypot(W, H) * 1.15;
    var spread = opts.spread * Math.PI / 180;
    var tilt = (src.x - 0.5) * -0.35;
    ctx.globalCompositeOperation = "lighter";
    for (var i = 0; i < rays.length; i++) {
      var r = rays[i];
      var sw = Math.sin(tNow * r.sp + r.ph) * 0.07 * opts.sway;
      var ang = Math.PI / 2 + tilt + (r.u - 0.5) * spread + sw;
      var hw = r.w * (0.8 + 0.3 * Math.sin(tNow * r.sp * 0.7 + r.ph));
      var ex = sx + Math.cos(ang) * L, ey = sy + Math.sin(ang) * L;
      var nx = -Math.sin(ang), ny = Math.cos(ang), wd = L * hw;
      var col = r.k ? "#c6f26b" : opts.accent;
      var g = ctx.createLinearGradient(sx, sy, ex, ey);
      var al = opts.intensity * 0.5 * r.a;
      g.addColorStop(0, rgba(col, al)); g.addColorStop(0.45, rgba(col, al * 0.35)); g.addColorStop(1, rgba(col, 0));
      ctx.fillStyle = g;
      ctx.beginPath(); ctx.moveTo(sx - nx * 3, sy - ny * 3); ctx.lineTo(ex + nx * wd, ey + ny * wd); ctx.lineTo(ex - nx * wd, ey - ny * wd); ctx.lineTo(sx + nx * 3, sy + ny * 3); ctx.closePath(); ctx.fill();
    }
    var gl = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(W, H) * 0.55);
    gl.addColorStop(0, rgba(opts.accent, opts.intensity * 0.55)); gl.addColorStop(0.35, "rgba(10,107,70,0.25)"); gl.addColorStop(1, "rgba(10,107,70,0)");
    ctx.fillStyle = gl; ctx.fillRect(0, 0, W, H);
    ctx.globalCompositeOperation = "source-over";
    var fade = ctx.createLinearGradient(0, H * 0.55, 0, H);
    fade.addColorStop(0, "rgba(12,30,24,0)"); fade.addColorStop(1, "rgba(12,30,24,0.65)");
    ctx.fillStyle = fade; ctx.fillRect(0, 0, W, H);
  }
  function loop(t) {
    raf = 0;
    if (!visible) return;
    tNow = (t - t0) / 1000;
    src.x += (target.x - src.x) * 0.06;
    draw();
    raf = requestAnimationFrame(loop);
  }
  function play() { if (!reduce && visible && !raf) raf = requestAnimationFrame(loop); }
  function pmove(e) {
    var r = wrap.getBoundingClientRect();
    target.x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
    if (reduce) { src.x = target.x; draw(); }
  }
  function pleave() { target.x = 0.5; if (reduce) { src.x = 0.5; draw(); } }
  wrap.addEventListener("pointermove", pmove);
  wrap.addEventListener("pointerleave", pleave);
  offs.push(function () { wrap.removeEventListener("pointermove", pmove); wrap.removeEventListener("pointerleave", pleave); });
  if (window.ResizeObserver) { var ro = new ResizeObserver(size); ro.observe(wrap); offs.push(function () { ro.disconnect(); }); }
  else { window.addEventListener("resize", size); offs.push(function () { window.removeEventListener("resize", size); }); }
  if (window.IntersectionObserver) {
    var io = new IntersectionObserver(function (en) {
      visible = en[en.length - 1].isIntersecting;
      if (visible) play(); else if (raf) { cancelAnimationFrame(raf); raf = 0; }
    });
    io.observe(wrap); offs.push(function () { io.disconnect(); });
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", rays: 12, intensity: 0.6, sway: 0.6, spread: 90 }, opts, next || {});
    opts.rays = Math.max(3, Math.min(24, Math.round(opts.rays)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    if (prev.rays !== opts.rays) build();
    if (W) draw();
  }
  update(o);
  size();
  play();
  return {
    update: update,
    destroy: function () {
      if (raf) cancelAnimationFrame(raf);
      raf = 0; visible = false;
      offs.forEach(function (f) { f(); }); offs = [];
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","rays":12,"intensity":0.6,"sway":0.6,"spread":90});

How it works

A source point sits just above the top edge. Each ray is a thin wedge from the source to a far point at its own angle, filled with a linear gradient that fades from the ray colour to transparent. The wedges are drawn with additive blending so where they overlap the light builds up like real shafts. Every ray has its own phase and speed, so its angle and width drift with a sine wave for the gentle sway. The pointer x position eases the source sideways and tilts the whole fan. A radial glow at the source and a soft dark fade at the bottom finish the look. Three Enrichr puzzle pieces float above in an inline SVG layer.

Accessibility and performance

The canvas and puzzle art are decorative and hidden from assistive tech. Reduced motion draws one still frame with no sway or float, and the pointer still repositions the source without animation. Motion is slow and low contrast, but keep body text on a solid or darkened area for readability.

One canvas redrawn per frame with a handful of gradient fills, which is cheap for up to 24 rays. The canvas is capped at 2x pixel ratio, resized through a ResizeObserver, and the loop stops when the element is off-screen.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, Canvas 2D, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byLight Rays (React Bits) by React Bits on reactbits.dev. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Can the rays be a different colour?

Set the accent in update(). A second, lighter lime is mixed in automatically; both stay within the Enrichr palette.

How do I make the light stay still?

Set sway to 0 and the rays hold their angles; only the pointer will move the source.

Can the source be somewhere other than the top?

This version fixes it above the frame. Change the sy value in draw() and the base angle to move it.