Copied!
Background

Iridescent sheen in brand greens

A soft, flowing thin-film colour shift made in a WebGL shader and kept to the brand greens, lime and cream, with the pointer acting as a moving light and puzzle pieces floating on top.

← Library

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

3DElementWebGL + GLSL + Vanilla JS + 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 "Iridescent sheen in brand greens" for my site using the Enrichr Library element at https://enrichr.dev/elements/iridescence-sheen.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/iridescence-sheen.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:
- text: Quiet colour, always moving
- palette: lime
- speed: 1
- scale: 1.8
- light: 0.7

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/iridescence-sheen.json.

Code

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

HTML

<div class="ef-irs">
  <div class="ef-irs__wrap">
    <canvas class="ef-irs__cv" aria-hidden="true"></canvas>
    <svg class="ef-irs__defs" width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
      <symbol id="irs-puzzlePiece1" 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="irs-puzzlePiece4" 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="irs-puzzlePiece8" 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>
    </svg>
    <div class="ef-irs__art" aria-hidden="true">
      <svg class="ef-irs__pz ef-irs__pz--a" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet"><use href="#irs-puzzlePiece1" width="1" height="1"/></svg>
      <svg class="ef-irs__pz ef-irs__pz--b" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet"><use href="#irs-puzzlePiece4" width="1" height="1"/></svg>
      <svg class="ef-irs__pz ef-irs__pz--c" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet"><use href="#irs-puzzlePiece8" width="1" height="1"/></svg>
    </div>
    <p class="ef-irs__txt"></p>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-irs { width: 100%; height: 100%; min-height: 18rem; }
.ef-irs__wrap { position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 18rem; overflow: hidden; border-radius: 1rem; background: linear-gradient(135deg, #005236 0%, #2f7d5b 38%, #c6f26b 70%, #fbf8f1 100%); color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; isolation: isolate; }
.ef-irs__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-irs.is-nogl .ef-irs__cv { display: none; }
.ef-irs.is-nogl .ef-irs__wrap { animation: ef-irs-fb 9s ease-in-out infinite alternate; background-size: 200% 200%; }
.ef-irs__art { position: absolute; inset: 0; pointer-events: none; }
.ef-irs__pz { position: absolute; display: block; color: var(--ef-pz, #fbf8f1); opacity: 0.88; filter: drop-shadow(0 10px 14px rgba(12, 30, 24, 0.35)); animation: ef-irs-float 7s ease-in-out infinite; transform: translate(var(--px, 0px), var(--py, 0px)) rotate(var(--r, 0deg)); will-change: transform; }
.ef-irs__pz--a { width: 5.2rem; height: 5.2rem; left: 12%; top: 16%; --r: -12deg; }
.ef-irs__pz--b { width: 8.4rem; height: 5.4rem; right: 9%; top: 22%; --r: 9deg; animation-delay: -2.4s; color: var(--ef-pz2, #e6ff55); }
.ef-irs__pz--c { width: 4.2rem; height: 6.6rem; left: 24%; bottom: 10%; --r: 14deg; animation-delay: -4.6s; color: var(--ef-pz3, #0c1e18); }
.ef-irs__txt { position: absolute; left: 1.4rem; right: 1.4rem; bottom: 1.2rem; margin: 0; font-size: 1.35rem; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; color: #fbf8f1; text-shadow: 0 2px 14px rgba(12, 30, 24, 0.55); pointer-events: none; }
.ef-irs__txt:empty { display: none; }
@keyframes ef-irs-float { 0%, 100% { translate: 0 0; } 50% { translate: 0 -0.7rem; } }
@keyframes ef-irs-fb { from { background-position: 0% 0%; } to { background-position: 100% 100%; } }
@media (prefers-reduced-motion: reduce) { .ef-irs__pz, .ef-irs.is-nogl .ef-irs__wrap { animation: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Iridescence sheen: a soft thin-film colour shift made in a raw WebGL fragment shader, kept inside the brand greens, lime and cream.
// A flowing noise field acts as film thickness; the pointer is a light that bends the viewing angle and adds a highlight.
// Falls back to a CSS gradient without WebGL. Reduced motion draws a single still frame. Pauses off-screen.
// mount(root, { text, speed (0-3), scale (0.5-4), light (0-1), palette ("lime"|"forest"|"cream") })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var host = root.querySelector(".ef-irs");
  var wrap = root.querySelector(".ef-irs__wrap");
  var cv = root.querySelector(".ef-irs__cv");
  var txt = root.querySelector(".ef-irs__txt");
  var pz = root.querySelectorAll(".ef-irs__pz");
  var opts = {}, gl = null, prog = null, U = {}, raf = 0, visible = true, dead = false;
  var t0 = performance.now(), tAcc = 0, last = t0;
  var ptr = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5 };
  var PAL = { lime: [0.1, 0.92], forest: [0.04, 0.6], cream: [0.45, 1.0] };

  var VS = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
  var FS = [
    "precision mediump float;",
    "uniform vec2 uRes;uniform float uT;uniform vec2 uP;uniform float uScale;uniform float uLight;uniform vec2 uPal;",
    "vec3 ink=vec3(.047,.118,.094);vec3 g1=vec3(0.,.322,.212);vec3 g2=vec3(.039,.42,.275);vec3 g3=vec3(.184,.49,.357);",
    "vec3 l1=vec3(.776,.949,.42);vec3 l2=vec3(.902,1.,.333);vec3 cr=vec3(.984,.973,.945);",
    // brand ramp: ink > deep green > green > mid green > soft lime > lime > cream
    "vec3 ramp(float t){t=clamp(t,0.,1.)*6.;",
    " if(t<1.)return mix(ink,g1,t);if(t<2.)return mix(g1,g2,t-1.);if(t<3.)return mix(g2,g3,t-2.);",
    " if(t<4.)return mix(g3,l1,t-3.);if(t<5.)return mix(l1,l2,t-4.);return mix(l2,cr,t-5.);}",
    "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 a=.5,s=0.;for(int i=0;i<4;i++){s+=a*n(p);p=p*2.03+vec2(1.7,9.2);a*=.5;}return s;}",
    "void main(){",
    " vec2 uv=gl_FragCoord.xy/uRes;vec2 q=(uv-.5)*vec2(uRes.x/uRes.y,1.)*uScale;",
    " vec2 w=vec2(fbm(q+vec2(0.,uT*.12)),fbm(q+vec2(5.2,1.3)-uT*.1));",
    " float thick=fbm(q+2.2*w+uT*.05);",
    // light vector from pointer: acts like viewing angle
    " vec2 lv=uv-uP;float d=length(lv*vec2(uRes.x/uRes.y,1.));",
    " float ang=(1.-smoothstep(0.,.9,d))*uLight;",
    " float film=thick*1.6+ang*.9+w.x*.5;",
    // thin-film: cosine bands remapped into the brand ramp only
    " float band=.5+.5*cos(6.2831*film);",
    " vec3 c=ramp(mix(uPal.x,uPal.y,band));",
    " c+=l2*pow(max(0.,1.-d*2.2),3.)*uLight*.28;",
    " c=mix(c,cr,smoothstep(.55,1.,band)*.18*(.4+uLight));",
    " float v=smoothstep(1.2,.2,length(uv-.5));c*=.72+.28*v;",
    " gl_FragColor=vec4(c,1.);}"
  ].join("\n");

  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 init() {
    try { gl = cv.getContext("webgl", { antialias: false, alpha: false }) || 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);
    ["uRes", "uT", "uP", "uScale", "uLight", "uPal"].forEach(function (k) { U[k] = gl.getUniformLocation(prog, k); });
    return true;
  }
  function size() {
    var r = wrap.getBoundingClientRect();
    var dpr = Math.min(window.devicePixelRatio || 1, 1.5);
    var w = Math.max(2, Math.round(r.width * dpr)), h = Math.max(2, Math.round(r.height * dpr));
    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 pal = PAL[opts.palette] || PAL.lime;
    gl.uniform2f(U.uRes, cv.width, cv.height);
    gl.uniform1f(U.uT, tAcc);
    gl.uniform2f(U.uP, ptr.x, ptr.y);
    gl.uniform1f(U.uScale, opts.scale);
    gl.uniform1f(U.uLight, opts.light);
    gl.uniform2f(U.uPal, pal[0], pal[1]);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }
  function frame(now) {
    raf = 0;
    if (dead || !visible) return;
    var dt = Math.min(0.05, (now - last) / 1000); last = now;
    tAcc += dt * opts.speed;
    ptr.x += (ptr.tx - ptr.x) * 0.08; ptr.y += (ptr.ty - ptr.y) * 0.08;
    var px = (ptr.x - 0.5) * 2, py = (ptr.y - 0.5) * 2;
    for (var i = 0; i < pz.length; i++) {
      var k = (i + 1) * 7;
      pz[i].style.setProperty("--px", (px * k).toFixed(1) + "px");
      pz[i].style.setProperty("--py", (-py * k).toFixed(1) + "px");
    }
    draw();
    raf = requestAnimationFrame(frame);
  }
  function start() { if (!raf && !reduce && !dead && visible) { last = performance.now(); raf = requestAnimationFrame(frame); } }
  function stop() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }
  function still() { if (reduce) { size(); draw(); } }

  function onMove(e) {
    var r = wrap.getBoundingClientRect();
    ptr.tx = (e.clientX - r.left) / r.width;
    ptr.ty = 1 - (e.clientY - r.top) / r.height;
  }
  function onLeave() { ptr.tx = 0.5; ptr.ty = 0.5; }

  var io = new IntersectionObserver(function (es) {
    visible = es[0].isIntersecting;
    if (visible) start(); else stop();
  });
  var ro = new ResizeObserver(function () { size(); still(); });
  var ok = init();
  if (!ok) host.classList.add("is-nogl");
  else {
    wrap.addEventListener("pointermove", onMove);
    wrap.addEventListener("pointerleave", onLeave);
    io.observe(wrap); ro.observe(wrap);
  }

  function update(next) {
    opts = Object.assign({ text: "Quiet colour, always moving", speed: 1, scale: 1.8, light: 0.7, palette: "lime", accent: "#fbf8f1" }, opts, next || {});
    opts.speed = Math.max(0, Math.min(3, +opts.speed));
    opts.scale = Math.max(0.5, Math.min(4, +opts.scale));
    opts.light = Math.max(0, Math.min(1, +opts.light));
    txt.textContent = opts.text;
    host.style.setProperty("--ef-pz", opts.accent);
    if (ok) { size(); still(); }
  }
  update(o);
  if (ok) start();
  return {
    update: update,
    destroy: function () {
      dead = true; stop();
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerleave", onLeave);
      io.disconnect(); ro.disconnect();
      if (gl) { var x = gl.getExtension("WEBGL_lose_context"); if (x) x.loseContext(); }
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Quiet colour, always moving","palette":"lime","speed":1,"scale":1.8,"light":0.7});

How it works

A full-screen triangle runs a fragment shader. Layered noise, warped by a second noise field, stands in for film thickness. A cosine of that thickness produces repeating bands, and the band value is looked up on a seven-stop ramp built only from the brand colours (ink, deep green, mid green, lime, cream), so the colour shifts like a thin film but never leaves the palette. The pointer position is a light: it adds to the thickness near the cursor, which slides the bands, and adds a soft lime highlight. The pointer is eased so the light drifts rather than snaps. The wrapper has a CSS gradient behind the canvas, so if WebGL is unavailable the canvas is hidden and a slowly shifting gradient shows instead. Three puzzle-piece SVGs float above, nudged a few pixels by the pointer.

Accessibility and performance

The canvas and puzzle pieces are decorative and hidden from assistive tech. With prefers-reduced-motion the shader draws one still frame and the pieces stop floating. The caption sits on a text shadow; check contrast on the cream palette against your own text colour.

One draw call per frame with a four-octave noise, at up to 1.5x pixel ratio. It stops when scrolled off-screen and the WebGL context is released on destroy. On weak GPUs lower the pattern scale or the canvas size.

SectionBackground
TechniquesWebGL, Pointer interaction
StackWebGL, GLSL, Vanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byIridescence (React Bits backgrounds), general effect only 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 photo and its depth map drawn by a WebGL shader, so the near parts of the picture move more than the far ones as the pointer moves. Wave image WebGLA generated artwork on a raw WebGL mesh that ripples with sine waves and swells where the pointer moves. Glass torus heroA glossy glass torus knot turns slowly in front of a huge three-line headline, bending the letters and splitting their edges into colour.

FAQ

Can it use other colours?

The ramp is defined at the top of the shader as a handful of vec3 values. Swap them for any set of brand colours and keep the order dark to light.

What happens without WebGL?

The canvas is hidden and the CSS gradient on the wrapper shows, drifting slowly. Reduced motion stops that too.

Does it need a library?

No. It uses the raw WebGL API with a single shader pair.