Copied!
Background

Aurora background

Soft northern-lights ribbons drift across a dark sky in a small WebGL shader, bend toward the pointer, and float Enrichr puzzle pieces in the foreground.

← Library

Element · Vanilla JS + WebGL + CSSUpdated November 6, 2026

3DElementVanilla JS + WebGL + CSS

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 "Aurora background" for my site using the Enrichr Library element at https://enrichr.dev/elements/aurora-background.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/aurora-background.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
- speed: 0.6
- intensity: 1
- pull: 0.6
- pieces: on

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/aurora-background.json.

Code

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

HTML

<div class="ef-aur">
  <div class="ef-aur-fb"></div>
  <canvas class="ef-aur-cv" aria-hidden="true"></canvas>
  <svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs><symbol id="ef-aur-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-aur-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-aur-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></svg>
  <svg class="ef-aur-pz ef-aur-pz--a" aria-hidden="true"><use href="#ef-aur-pz1"/></svg>
  <svg class="ef-aur-pz ef-aur-pz--b" aria-hidden="true"><use href="#ef-aur-pz4"/></svg>
  <svg class="ef-aur-pz ef-aur-pz--c" aria-hidden="true"><use href="#ef-aur-pz8"/></svg>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-aur { position: relative; width: 100%; height: 100%; min-height: 18rem; overflow: hidden; background: #0c1e18; isolation: isolate; }
.ef-aur-fb { position: absolute; inset: 0; background: radial-gradient(ellipse 80% 40% at 30% 55%, rgba(230,255,85,0.35), transparent 70%), radial-gradient(ellipse 70% 45% at 70% 40%, rgba(47,125,91,0.7), transparent 70%), linear-gradient(180deg, #0c1e18, #005236 120%); }
.ef-aur-cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 600ms; }
.ef-aur.is-gl .ef-aur-cv { opacity: 1; }
.ef-aur.is-gl .ef-aur-fb { opacity: 0; }
.ef-aur-pz { position: absolute; color: #fbf8f1; opacity: 0.16; pointer-events: none; will-change: transform; filter: drop-shadow(0 6px 18px rgba(0,0,0,0.35)); }
.ef-aur-pz--a { aspect-ratio: 1; width: 22%; left: 8%; bottom: 10%; transform: rotate(-14deg); animation: ef-aur-fa 11s ease-in-out infinite; }
.ef-aur-pz--b { aspect-ratio: 503 / 322; width: 34%; right: 6%; top: 12%; opacity: 0.12; color: #e6ff55; transform: rotate(9deg); animation: ef-aur-fb 14s ease-in-out infinite; }
.ef-aur-pz--c { aspect-ratio: 322 / 503; width: 12%; left: 46%; bottom: 20%; opacity: 0.2; color: #c6f26b; transform: rotate(22deg); animation: ef-aur-fc 9s ease-in-out infinite; }
@keyframes ef-aur-fa { 50% { transform: translateY(-14px) rotate(-8deg); } }
@keyframes ef-aur-fb { 50% { transform: translateY(12px) rotate(14deg); } }
@keyframes ef-aur-fc { 50% { transform: translateY(-10px) rotate(16deg); } }
@media (prefers-reduced-motion: reduce) { .ef-aur-pz { animation: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Aurora background: soft ribbons of light drift across a dark sky, drawn by a small WebGL fragment shader (layered noise curtains).
// The pointer gently bends the ribbons toward it. Reduced motion draws one still frame. If WebGL is missing, a CSS gradient shows instead.
// Original rebuild from the general idea of an aurora; palette is the Enrichr brand greens and lime only.
// mount(root, { accent, speed (0.1-2), intensity (0.3-1.5), pull (0-1), pieces ("on"|"off") })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-aur");
  var cv = root.querySelector(".ef-aur-cv");
  var pcs = root.querySelectorAll(".ef-aur-pz");
  var opts = {}, gl = null, prog = null, U = {}, raf = 0, vis = true, io = null, ro = null;
  var t0 = performance.now(), tAcc = 0, last = t0;
  var mx = 0.5, my = 0.5, tx = 0.5, ty = 0.5, push = 0;
  var offs = [];

  var VS = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
  var FS = [
    "precision mediump float;",
    "uniform vec2 R;uniform float T;uniform vec2 M;uniform float PU;uniform float IN;uniform vec3 A;",
    "float h(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}",
    "float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);",
    "return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+vec2(1,1)),f.x),f.y);}",
    "float fbm(vec2 p){float s=0.,a=.5;for(int i=0;i<4;i++){s+=a*n(p);p=p*2.03+vec2(7.1,3.7);a*=.5;}return s;}",
    "vec3 ribbon(vec2 uv,float k,float base,float spd,vec3 col){",
    "  float x=uv.x*1.6+k*3.1;",
    "  float c=base+.16*sin(x*1.7+T*spd)+.22*(fbm(vec2(x*.9,T*spd*.35+k*5.))-.5);",
    "  vec2 d=uv-M;float bump=exp(-dot(d,d)*6.)*PU;c+=bump*(M.y-c)*.5;",
    "  float w=.07+.09*fbm(vec2(x*1.3+9.,T*.05*spd+k));",
    "  float dy=uv.y-c;",
    "  float core=exp(-dy*dy/(w*w));",
    "  float hang=smoothstep(0.,.5,dy)*exp(-dy*3.2);",
    "  float rays=.55+.45*fbm(vec2(uv.x*38.+k*11.,T*.08*spd));",
    "  float body=(core*.9+hang*(.35+.4*step(0.,dy)))*rays;",
    "  return col*body;",
    "}",
    "void main(){",
    "  vec2 uv=gl_FragCoord.xy/R;uv.x*=R.x/R.y;vec2 m=M;",
    "  vec3 ink=vec3(.047,.118,.094);",
    "  vec3 g1=vec3(0.,.322,.212),g2=vec3(.039,.42,.275),g3=vec3(.184,.49,.357),lime=A;",
    "  vec3 col=mix(ink,vec3(0.,.12,.08),uv.y*.0+.0);",
    "  col+=ribbon(uv,0.,.52,1.0,g3)*1.0;",
    "  col+=ribbon(uv,1.,.60,.8,g2)*1.15;",
    "  col+=ribbon(uv,2.,.46,1.2,lime)*.7;",
    "  col+=ribbon(uv,3.,.68,.6,g1)*1.2;",
    "  col=ink+(col-ink)*IN;",
    "  float st=step(.9965,h(floor(gl_FragCoord.xy/2.)));col+=st*vec3(.98,.97,.945)*.55*smoothstep(.3,.8,uv.y);",
    "  col=min(col,vec3(.93,1.,.45));",
    "  gl_FragColor=vec4(col,1.);",
    "}"
  ].join("\n");
  // note: uv.x is scaled by aspect, so M is passed in the same space from JS.

  function sh(type, src) {
    var s = gl.createShader(type);
    gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { gl.deleteShader(s); return null; }
    return s;
  }
  function init() {
    try { gl = cv.getContext("webgl", { antialias: false, alpha: false, powerPreference: "low-power" }) || cv.getContext("experimental-webgl"); } catch (e) { gl = null; }
    if (!gl) return false;
    var v = sh(gl.VERTEX_SHADER, VS), f = sh(gl.FRAGMENT_SHADER, FS);
    if (!v || !f) { gl = null; return false; }
    prog = gl.createProgram();
    gl.attachShader(prog, v); gl.attachShader(prog, f); gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { gl = null; return false; }
    gl.useProgram(prog);
    var b = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, b);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    var loc = gl.getAttribLocation(prog, "p");
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
    ["R", "T", "M", "PU", "IN", "A"].forEach(function (k) { U[k] = gl.getUniformLocation(prog, k); });
    box.classList.add("is-gl");
    return true;
  }
  function hex(h) {
    var m = /^#?([0-9a-f]{6})$/i.exec(h || "");
    var n = parseInt(m ? m[1] : "e6ff55", 16);
    return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
  }
  function size() {
    var r = box.getBoundingClientRect();
    var d = Math.min(window.devicePixelRatio || 1, 1.5) * 0.6;
    var w = Math.max(2, Math.round(r.width * d)), h = Math.max(2, Math.round(r.height * d));
    if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
    if (gl) gl.viewport(0, 0, w, h);
  }
  function draw() {
    if (!gl) return;
    var asp = cv.width / cv.height;
    gl.uniform2f(U.R, cv.width, cv.height);
    gl.uniform1f(U.T, tAcc);
    gl.uniform2f(U.M, mx * asp, 1 - my);
    gl.uniform1f(U.PU, push * opts.pull);
    gl.uniform1f(U.IN, opts.intensity);
    var c = hex(opts.accent);
    gl.uniform3f(U.A, c[0], c[1], c[2]);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }
  function frame(now) {
    raf = 0;
    var dt = Math.min(0.05, (now - last) / 1000); last = now;
    tAcc += dt * opts.speed;
    mx += (tx - mx) * 0.06; my += (ty - my) * 0.06;
    push += ((tx !== 0.5 || ty !== 0.5 ? 1 : 0) - push) * 0.04;
    draw();
    if (vis && !reduce) raf = requestAnimationFrame(frame);
  }
  function kick() {
    if (reduce) { size(); tAcc = 6; draw(); return; }
    if (!raf && vis) { last = performance.now(); raf = requestAnimationFrame(frame); }
  }
  function on(el, ev, fn) { el.addEventListener(ev, fn); offs.push(function () { el.removeEventListener(ev, fn); }); }

  var ok = init();
  on(cv, "webglcontextlost", function (e) { e.preventDefault(); if (raf) cancelAnimationFrame(raf); raf = 0; box.classList.remove("is-gl"); });
  on(cv, "webglcontextrestored", function () { if (init()) { size(); kick(); } });
  on(box, "pointermove", function (e) {
    var r = box.getBoundingClientRect();
    tx = (e.clientX - r.left) / r.width; ty = (e.clientY - r.top) / r.height;
  });
  on(box, "pointerleave", function () { tx = 0.5; ty = 0.5; });
  if (ok) {
    if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { size(); if (reduce) draw(); }); ro.observe(box); }
    else on(window, "resize", function () { size(); });
    if ("IntersectionObserver" in window) {
      io = new IntersectionObserver(function (en) { vis = en[0].isIntersecting; if (vis) kick(); else if (raf) { cancelAnimationFrame(raf); raf = 0; } });
      io.observe(box);
    }
    size();
  }
  function update(next) {
    opts = Object.assign({ accent: "#e6ff55", speed: 0.6, intensity: 1, pull: 0.6, pieces: "on" }, opts, next || {});
    for (var i = 0; i < pcs.length; i++) pcs[i].style.display = opts.pieces !== "off" && opts.pieces !== false ? "" : "none";
    box.style.setProperty("--ef-acc", opts.accent);
    if (ok) kick();
  }
  function destroy() {
    if (raf) cancelAnimationFrame(raf); raf = 0; vis = false;
    if (io) io.disconnect(); if (ro) ro.disconnect();
    offs.forEach(function (f) { f(); }); offs = [];
    gl = null;
  }
  update(o);
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","speed":0.6,"intensity":1,"pull":0.6,"pieces":"on"});

How it works

One full-screen triangle is drawn with a fragment shader. Four ribbons each follow a sine curve bent by fractal value noise; a Gaussian across the ribbon gives a bright core, an exponential fall-off gives the hanging curtain, and a high-frequency noise term multiplies in the vertical rays. The pointer adds a smooth bump that pulls nearby ribbon centres toward it, eased over time in JavaScript. The canvas renders at about 60 percent of CSS pixels because the effect is soft anyway. Inline SVG puzzle pieces float above with a CSS keyframe drift.

Accessibility and performance

The background is decorative: the canvas and pieces are aria-hidden and take no focus. Put your own text on top and check contrast against the brightest ribbon. With prefers-reduced-motion the shader draws one still frame and the pieces stop floating. If WebGL is unavailable or the context is lost, a static CSS gradient in the same greens is shown.

A single draw call per frame at reduced resolution, capped device pixel ratio, and no work while off-screen thanks to IntersectionObserver. Lower the drift speed or shrink the box on weak GPUs. The loop also stops entirely under reduced motion.

SectionBackground
TechniquesWebGL, Pointer interaction
StackVanilla JS, WebGL, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byAurora background 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.

Depth map shaderA WebGL shader that shifts an image by its own depth and the pointer, so near parts move more than far ones. Wave image WebGLA generated artwork on a raw WebGL mesh that ripples with sine waves and swells where the pointer moves. Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side.

FAQ

Can I change the colours?

The accent is a parameter. The other greens and the ink background are constants in the shader; edit the vec3 values to retheme.

What if WebGL fails?

The canvas stays hidden and the CSS gradient layer underneath shows. A lost context is handled and restored automatically.

Does it need a fixed size?

No. It fills its parent and resizes with a ResizeObserver; the parent should have a height or the min-height of 18rem applies.