Copied!
Background

Glow orb that swells at the pointer

A soft, pulsing, 3D-looking orb built from layered radial gradients and a noise-wobbled edge that swells and tilts its highlight toward the pointer.

← Library

Element · Vanilla JS + Canvas 2DUpdated November 6, 2026

BackgroundElementVanilla JS + Canvas 2D

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 "Glow orb that swells at the pointer" for my site using the Enrichr Library element at https://enrichr.dev/elements/glow-orb.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/glow-orb.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
- size: 0.55
- pulse: 0.6
- wobble: 0.6
- swell: 0.6

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/glow-orb.json.

Code

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

HTML

<div class="ef-glo">
  <div class="ef-glo__wrap">
    <canvas class="ef-glo__cv" aria-hidden="true"></canvas>
    <svg class="ef-glo__piece" viewBox="1513.7 1453.9 322.3 322.3" aria-hidden="true" focusable="false"><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"/></svg>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-glo { width: 100%; height: 100%; }
.ef-glo__wrap { position: relative; box-sizing: border-box; width: 100%; min-height: 20rem; height: 100%; border-radius: 1rem; overflow: hidden; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-glo__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-glo__piece { position: absolute; left: 50%; top: 50%; width: 3.4rem; height: 3.4rem; margin: -1.7rem 0 0 -1.7rem; color: #fbf8f1; opacity: 0.6; pointer-events: none; transform: translate(var(--px, 0px), var(--py, 0px)) scale(var(--ps, 1)); filter: drop-shadow(0 2px 6px rgba(12, 30, 24, 0.5)); }

JavaScript

// Enrichr Library · https://enrichr.dev
// Glow orb: a soft, pulsing, 3D-looking sphere in canvas 2D (an original build, not a port). The silhouette is displaced by layered sine noise,
// then filled with stacked radial gradients (body, drifting light bands, shadow, rim light, specular). The pointer makes it swell and slides the highlight.
// Reduced motion draws one still frame. Pauses off-screen.
// mount(root, { background, accent, size (0.3-0.9), pulse (0-1), wobble (0-1), swell (0-1) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-glo__wrap");
  var cv = root.querySelector(".ef-glo__cv");
  var piece = root.querySelector(".ef-glo__piece");
  var ctx = cv.getContext("2d");
  var opts = {}, W = 0, H = 0, dpr = 1, raf = 0, visible = true, dead = false;
  var hx = 0, hy = 0, thx = 0, thy = 0, sw = 0, tsw = 0, clock = 0, last = 0;

  function rgba(hex, a) {
    var h = hex.replace("#", "");
    if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
    var n = parseInt(h, 16);
    return "rgba(" + (n >> 16) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
  }
  function size() {
    var rc = wrap.getBoundingClientRect();
    W = Math.max(40, rc.width); H = Math.max(40, rc.height);
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
    draw();
  }

  function draw() {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.globalAlpha = 1;
    ctx.fillStyle = opts.background;
    ctx.fillRect(0, 0, W, H);
    var cx = W / 2, cy = H / 2, m = Math.min(W, H);
    var breathe = 1 + Math.sin(clock * 1.6) * 0.035 * opts.pulse;
    var R = m * opts.size * 0.5 * breathe * (1 + sw * 0.16 * opts.swell);
    var halo = ctx.createRadialGradient(cx, cy, R * 0.6, cx, cy, R * 1.9);
    halo.addColorStop(0, rgba(opts.accent, 0.3 + sw * 0.2 * opts.swell)); halo.addColorStop(0.45, "rgba(47,125,91,0.14)"); halo.addColorStop(1, "rgba(12,30,24,0)");
    ctx.fillStyle = halo; ctx.fillRect(0, 0, W, H);
    var N = 96, amp = R * 0.03 * opts.wobble * (1 + sw * 0.8);
    ctx.beginPath();
    for (var i = 0; i <= N; i++) {
      var a = i / N * Math.PI * 2;
      var d = Math.sin(a * 3 + clock * 1.1) * 0.5 + Math.sin(a * 5 - clock * 1.7 + 1.3) * 0.3 + Math.sin(a * 2 + clock * 0.7 + 4) * 0.35;
      var r = R + d * amp;
      var x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;
      if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
    }
    ctx.closePath();
    ctx.save();
    ctx.clip();
    var bx0 = cx - R * 1.4, by0 = cy - R * 1.4, bs = R * 2.8;
    var lx = cx + hx * R * 0.45, ly = cy + hy * R * 0.45 - R * 0.15;
    var body = ctx.createRadialGradient(lx, ly, R * 0.05, cx, cy, R * 1.15);
    body.addColorStop(0, "#c6f26b"); body.addColorStop(0.28, "#2f7d5b"); body.addColorStop(0.62, "#0a6b46"); body.addColorStop(1, "#005236");
    ctx.fillStyle = body; ctx.fillRect(bx0, by0, bs, bs);
    ctx.globalCompositeOperation = "lighter";
    for (var k = 0; k < 3; k++) {
      var ph = clock * (0.35 + k * 0.17) + k * 2.1;
      var bx = cx + Math.cos(ph) * R * 0.45 - hx * R * 0.12, by = cy + Math.sin(ph * 1.3) * R * 0.4 - hy * R * 0.12;
      var bg = ctx.createRadialGradient(bx, by, 0, bx, by, R * (0.55 + k * 0.12));
      bg.addColorStop(0, rgba(k === 1 ? "#c6f26b" : opts.accent, 0.2)); bg.addColorStop(1, rgba(opts.accent, 0));
      ctx.fillStyle = bg; ctx.fillRect(bx0, by0, bs, bs);
    }
    ctx.globalCompositeOperation = "source-over";
    var sh = ctx.createRadialGradient(cx - hx * R * 0.3, cy - hy * R * 0.3 - R * 0.2, R * 0.5, cx, cy, R * 1.12);
    sh.addColorStop(0, "rgba(12,30,24,0)"); sh.addColorStop(1, "rgba(12,30,24,0.62)");
    ctx.fillStyle = sh; ctx.fillRect(bx0, by0, bs, bs);
    var rim = ctx.createRadialGradient(cx, cy, R * 0.82, cx, cy, R * 1.02);
    rim.addColorStop(0, rgba(opts.accent, 0)); rim.addColorStop(1, rgba(opts.accent, 0.42));
    ctx.fillStyle = rim; ctx.fillRect(bx0, by0, bs, bs);
    var sx = cx + hx * R * 0.5 - R * 0.28, sy = cy + hy * R * 0.5 - R * 0.38;
    var sp = ctx.createRadialGradient(sx, sy, 0, sx, sy, R * (0.42 + sw * 0.12));
    sp.addColorStop(0, "rgba(251,248,241,0.9)"); sp.addColorStop(0.35, "rgba(230,255,85,0.35)"); sp.addColorStop(1, "rgba(230,255,85,0)");
    ctx.fillStyle = sp; ctx.fillRect(bx0, by0, bs, bs);
    ctx.restore();
    piece.style.setProperty("--px", (hx * R * 0.12).toFixed(1) + "px");
    piece.style.setProperty("--py", (hy * R * 0.12).toFixed(1) + "px");
    piece.style.setProperty("--ps", (R / (m * 0.275)).toFixed(3));
    piece.style.opacity = String(0.55 + sw * 0.3);
  }

  function frame(t) {
    raf = 0;
    if (dead || !visible) return;
    var dt = last ? Math.min(0.05, (t - last) / 1000) : 0.016; last = t;
    clock += dt;
    var k = Math.min(1, dt * 5);
    hx += (thx - hx) * k; hy += (thy - hy) * k; sw += (tsw - sw) * k;
    draw();
    raf = requestAnimationFrame(frame);
  }
  function start() { if (!reduce && !raf && visible && !dead) { last = 0; raf = requestAnimationFrame(frame); } }

  function pm(e) {
    if (e.pointerType === "touch") return;
    var r = wrap.getBoundingClientRect();
    var nx = ((e.clientX - r.left) / r.width - 0.5) * 2, ny = ((e.clientY - r.top) / r.height - 0.5) * 2;
    thx = Math.max(-1, Math.min(1, nx)); thy = Math.max(-1, Math.min(1, ny));
    tsw = Math.max(0, 1 - Math.sqrt(nx * nx + ny * ny) / 1.2);
    if (reduce) { hx = thx; hy = thy; sw = tsw; draw(); }
  }
  function pl() { thx = 0; thy = 0; tsw = 0; if (reduce) { hx = 0; hy = 0; sw = 0; draw(); } }
  wrap.addEventListener("pointermove", pm);
  wrap.addEventListener("pointerleave", pl);

  var io = null, ro = null;
  if (window.IntersectionObserver) {
    io = new IntersectionObserver(function (es) {
      visible = es[es.length - 1].isIntersecting;
      if (visible) start(); else if (raf) { cancelAnimationFrame(raf); raf = 0; }
    });
    io.observe(wrap);
  }
  if (window.ResizeObserver) { ro = new ResizeObserver(size); ro.observe(wrap); }

  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", size: 0.55, pulse: 0.6, wobble: 0.6, swell: 0.6 }, opts, next || {});
    wrap.style.background = opts.background;
    piece.style.color = "#fbf8f1";
    if (W) draw();
  }
  update(o);
  size();
  start();
  return {
    update: update,
    destroy: function () {
      dead = true;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      wrap.removeEventListener("pointermove", pm);
      wrap.removeEventListener("pointerleave", pl);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","size":0.55,"pulse":0.6,"wobble":0.6,"swell":0.6});

How it works

Each frame the orb outline is traced as a closed path whose radius is nudged by a few summed sine waves, so the edge breathes like a soft blob. That path becomes a clip, and inside it stack radial gradients: a green body lit from a movable point, three slow drifting light bands added with the lighter blend, a dark underside, a thin rim light, and a cream specular spot. The pointer position eases into a highlight offset and a swell amount that grows as the pointer nears the centre, which inflates the radius and strengthens the halo. A small puzzle piece sits on the orb and shifts a little with the highlight.

Accessibility and performance

The canvas and puzzle piece are decorative and hidden from assistive tech. Reduced motion draws one still frame, and the pointer simply re-draws that frame without any loop or easing. Touch pointers are ignored, and nothing flashes or strobes.

One canvas, about a dozen gradient fills per frame, no per-pixel work, so it is cheap. The loop pauses off-screen, the canvas is capped at 2x pixel ratio, and a ResizeObserver keeps it sized to its parent.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, Canvas 2D
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byOrb background (general idea only, original code) 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

Why does it use canvas rather than a shader?

Layered gradients clipped to a wobbling path give a convincing soft sphere without a WebGL context, so there is nothing to fall back from.

Can I use other colours?

The glow colour is a parameter. The body keeps the Enrichr greens, so for a different palette edit the colour stops in draw().

How do I stop the pulsing?

Set pulse and wobble to 0 and the orb holds still, while the pointer swell and highlight tilt keep working.