Copied!
Background

Shader gradient background

A slow, flowing noise gradient drawn with one WebGL fragment shader, with a CSS fallback.

← Library

Element · Vanilla JS + WebGLUpdated November 5, 2026

3DElementVanilla JS + WebGL

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

Read the machine-readable spec and source at https://enrichr.dev/elements/shader-gradient-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:
- colorA: #1b4d3e
- colorB: #c6f26b
- speed: 1
- scale: 2.2

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/shader-gradient-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-shader"><canvas class="ef-shader__c" aria-hidden="true"></canvas></div>

CSS

.ef-shader { position: absolute; inset: 0; overflow: hidden; background: linear-gradient(135deg, var(--ef-a, #1b4d3e), var(--ef-b, #c6f26b)); }
.ef-shader__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

JavaScript

// Shader gradient background: a slow, domain-warped noise gradient drawn with one WebGL fragment shader.
// Falls back to the plain CSS gradient when WebGL is missing, and draws a single still frame for reduced motion.
// Put it inside a positioned parent; the element fills it. mount(root, { colorA, colorB, speed, scale })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-shader");
  var canvas = root.querySelector(".ef-shader__c");
  var gl = canvas.getContext("webgl", { antialias: false, alpha: false, powerPreference: "low-power" });
  var opts = {}, raf = 0, visible = true, io = null, prog, u = {}, t0 = performance.now(), still = 2.0;
  var VS = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
  var FS = "precision mediump float;uniform vec2 uR;uniform float uT,uS;uniform vec3 uA,uB;" +
    "float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}" +
    "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 v=0.,k=.5;for(int i=0;i<4;i++){v+=k*n(p);p*=2.;k*=.5;}return v;}" +
    "void main(){vec2 uv=gl_FragCoord.xy/uR;uv.x*=uR.x/uR.y;vec2 p=uv*uS;" +
    "float q=fbm(p+vec2(uT*.07,0.));float w=fbm(p+3.*q+vec2(0.,uT*.05));" +
    "vec3 c=mix(uA,uB,smoothstep(.2,.8,w));c*=.65+.5*q;gl_FragColor=vec4(c,1.);}";

  function rgb(hex) {
    var m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
    var v = parseInt(m ? m[1] : "1b4d3e", 16);
    return [(v >> 16) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];
  }
  function sh(type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; }
  function setup() {
    var v = sh(gl.VERTEX_SHADER, VS), f = sh(gl.FRAGMENT_SHADER, FS);
    if (!v || !f) return false;
    prog = gl.createProgram(); gl.attachShader(prog, v); gl.attachShader(prog, f); gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) 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);
    ["uR", "uT", "uS", "uA", "uB"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
    return true;
  }
  function size() {
    var dpr = Math.min(window.devicePixelRatio || 1, 1.5);
    var w = Math.max(1, Math.round(wrap.clientWidth * dpr)), hh = Math.max(1, Math.round(wrap.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== hh) { canvas.width = w; canvas.height = hh; gl.viewport(0, 0, w, hh); }
  }
  function draw(t) {
    size();
    gl.uniform2f(u.uR, canvas.width, canvas.height);
    gl.uniform1f(u.uT, t);
    gl.uniform1f(u.uS, opts.scale);
    gl.uniform3fv(u.uA, rgb(opts.colorA));
    gl.uniform3fv(u.uB, rgb(opts.colorB));
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }
  function loop(now) {
    raf = requestAnimationFrame(loop);
    if (visible && !document.hidden) draw(((now - t0) / 1000) * opts.speed);
  }
  function update(next) {
    opts = Object.assign({ colorA: "#1b4d3e", colorB: "#c6f26b", speed: 1, scale: 2.2 }, opts, next || {});
    wrap.style.setProperty("--ef-a", opts.colorA);
    wrap.style.setProperty("--ef-b", opts.colorB);
    if (gl && prog && (reduce || !raf)) draw(still);
  }
  var ok = gl && setup();
  update(o);
  if (!ok) { canvas.style.display = "none"; return { update: update, destroy: function () {} }; }
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    raf = requestAnimationFrame(loop);
  } else {
    draw(still);
    window.addEventListener("resize", function () { draw(still); });
  }
  return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"colorA":"#1b4d3e","colorB":"#c6f26b","speed":1,"scale":2.2});

How it works

A full-screen triangle runs a fragment shader that builds fractal noise, warps it with a second noise lookup, and mixes your two colours along the result. Time is the only animated uniform. The canvas renders at no more than 1.5 times device pixel ratio, and the loop stops when the element is off screen or the tab is hidden.

Accessibility and performance

Decorative and hidden from assistive technology. Reduced motion draws one still frame. If WebGL is unavailable the container keeps a plain CSS gradient in the same two colours.

Four noise octaves at mediump precision, a capped pixel ratio, low-power preference and no animation when hidden. Check it on a low-end phone before putting text over it.

SectionBackground
TechniquesWebGL
StackVanilla JS, WebGL
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

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. Scroll camera landscapeA procedural low-poly mountain range that the camera flies through as you scroll, with a sun that moves from dawn to dusk. Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls.

FAQ

What happens without WebGL?

The canvas is hidden and the wrapper shows a linear gradient between your two colours.

Will it drain a laptop battery?

It renders only while visible, at a capped resolution, and asks the browser for the low-power GPU. For long pages, consider pausing it after the hero.

Can I add a third colour?

Yes. Add a uniform and mix it with the second colour on the noise value in the shader.