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 "Wave image WebGL" for my site using the Enrichr Library element at https://enrichr.dev/elements/wave-image-webgl.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/wave-image-webgl.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:
- strength: 0.6
- frequency: 5
- speed: 1
- colorA: #005236
- colorB: #e6ff55
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/wave-image-webgl.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-wave"><canvas class="ef-wave__c" role="img" aria-label="A generated landscape artwork rippling with waves that follow the pointer"></canvas></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-wave { position: relative; width: 100%; min-height: 20rem; overflow: hidden; border-radius: 1rem; background: linear-gradient(160deg, #0c1e18, var(--ef-wa, #005236)); touch-action: pan-y; cursor: crosshair; }
.ef-wave__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }JavaScript
// Enrichr Library · https://enrichr.dev
// Wave image WebGL: a canvas-painted artwork is mapped onto a mesh in raw WebGL and rippled by sine waves. Moving the pointer adds a ripple that follows it, and faster movement means taller waves.
// The artwork is generated in code (no image files). Without WebGL the still artwork is drawn on a 2D canvas; reduced motion shows the flat, calm picture.
// mount(root, { strength (0-1.5, wave height), frequency (1-12, wave count), speed (0-3), colorA (artwork green), colorB (artwork accent) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-wave");
var canvas = root.querySelector(".ef-wave__c");
var opts = {}, raf = 0, visible = true, io = null, ro = null, gl = null, prog, u = {}, tex, art = null, ctx2 = null, nIdx = 0;
var t0 = performance.now(), tx = 0, ty = 0, mx = 0, my = 0, energy = 0, lastX = null, lastY = 0, lastT = 0, destroyed = false;
var OV = 1.1, COLS = 72, ROWS = 48;
var VS = "attribute vec2 p;uniform float uT,uAmp,uFreq,uE,uAsp;uniform vec2 uM,uS;varying vec2 vUv;varying float vH;" +
"void main(){vec2 q=p*" + OV.toFixed(2) + ";" +
"float w=sin(q.x*uFreq+uT)*.5+sin(q.y*uFreq*.9-uT*1.3+q.x*1.5)*.35+sin((q.x+q.y)*uFreq*.5+uT*.7)*.15;" +
"vec2 d=(q-uM)*vec2(uAsp,1.);float r=length(d);" +
"float rip=sin(r*16.-uT*5.)*exp(-r*2.4)*uE;" +
"float h=w*uAmp+rip*(.25+uAmp);vH=h;" +
"vec2 pos=q;pos.y+=h*.045;" +
"vUv=p*.5*uS+.5+vec2(h*.012,h*.02);" +
"gl_Position=vec4(pos,0.,1.-h*.22);}";
var FS = "precision mediump float;uniform sampler2D uT0;varying vec2 vUv;varying float vH;" +
"void main(){vec3 c=texture2D(uT0,clamp(vUv,0.,1.)).rgb;" +
"c*=1.+vH*.5;c+=pow(max(vH,0.),3.)*.18;gl_FragColor=vec4(c,1.);}";
function hex(h, d) {
var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16);
return [v >> 16, (v >> 8) & 255, v & 255];
}
function mix(a, b, t) { return "rgb(" + [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * t); }).join(",") + ")"; }
function seeded(s) { return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function makeArt() {
var W = 1280, H = 800, c = document.createElement("canvas"); c.width = W; c.height = H;
var g = c.getContext("2d"), A = hex(opts.colorA, "005236"), B = hex(opts.colorB, "e6ff55"), D = [12, 30, 24], C = [251, 248, 241];
var bg = g.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, mix(D, A, 0.15)); bg.addColorStop(0.55, mix(D, A, 0.75)); bg.addColorStop(1, mix(A, B, 0.25));
g.fillStyle = bg; g.fillRect(0, 0, W, H);
var sx = W * 0.64, sy = H * 0.42, sr = H * 0.24;
var glow = g.createRadialGradient(sx, sy, sr * 0.4, sx, sy, sr * 2.6);
glow.addColorStop(0, "rgba(" + B.join(",") + ",.45)"); glow.addColorStop(1, "rgba(" + B.join(",") + ",0)");
g.fillStyle = glow; g.fillRect(0, 0, W, H);
var sun = g.createLinearGradient(0, sy - sr, 0, sy + sr);
sun.addColorStop(0, mix(B, C, 0.35)); sun.addColorStop(1, mix(B, A, 0.15));
g.fillStyle = sun; g.beginPath(); g.arc(sx, sy, sr, 0, 7); g.fill();
g.fillStyle = mix(D, A, 0.55);
for (var i = 0; i < 7; i++) { var y = sy + sr * 0.05 + i * sr * 0.14; g.fillRect(sx - sr, y, sr * 2, 3 + i * 2.2); }
var rnd = seeded(7);
for (var k = 0; k < 4; k++) {
var base = H * (0.58 + k * 0.1), amp = 34 + k * 10, ph = rnd() * 6, f1 = 0.004 + k * 0.0016, f2 = 0.011 + k * 0.003;
g.beginPath(); g.moveTo(0, H);
for (var x = 0; x <= W; x += 8) g.lineTo(x, base + Math.sin(x * f1 + ph) * amp + Math.sin(x * f2 + ph * 2) * amp * 0.4);
g.lineTo(W, H); g.closePath();
g.fillStyle = mix(A, D, 0.2 + k * 0.22); g.fill();
g.strokeStyle = "rgba(" + C.join(",") + "," + (0.1 + k * 0.06) + ")"; g.lineWidth = 2; g.stroke();
}
g.strokeStyle = "rgba(" + C.join(",") + ",.1)"; g.lineWidth = 1;
for (var vx = 0; vx < W; vx += 26) { g.beginPath(); g.moveTo(vx + 0.5, 0); g.lineTo(vx + 0.5, H); g.stroke(); }
g.strokeStyle = "rgba(" + C.join(",") + ",.35)"; g.lineWidth = 2;
for (var r = 1; r <= 3; r++) { g.beginPath(); g.arc(W * 0.2, H * 0.28, 34 * r, 0, 7); g.stroke(); }
g.fillStyle = "rgb(" + B.join(",") + ")"; g.beginPath(); g.arc(W * 0.2, H * 0.28, 9, 0, 7); g.fill();
g.fillStyle = "rgba(255,255,255,.035)";
for (var n = 0; n < 7000; n++) g.fillRect(rnd() * W, rnd() * H, 1.5, 1.5);
return c;
}
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 verts = [], idx = [], x, y;
for (y = 0; y <= ROWS; y++) for (x = 0; x <= COLS; x++) verts.push(x / COLS * 2 - 1, y / ROWS * 2 - 1);
for (y = 0; y < ROWS; y++) for (x = 0; x < COLS; x++) {
var a = y * (COLS + 1) + x, b = a + 1, c = a + COLS + 1, d = c + 1;
idx.push(a, b, c, b, d, c);
}
nIdx = idx.length;
var vb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vb); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
var ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW);
var loc = gl.getAttribLocation(prog, "p"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
["uT", "uAmp", "uFreq", "uE", "uAsp", "uM", "uS", "uT0"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
tex = gl.createTexture();
gl.uniform1i(u.uT0, 0);
return true;
}
function upload() {
if (!gl || !tex) return;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, art);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
function cover(w, h) {
var a = w / h, i = art.width / art.height;
return a > i ? [1, i / a] : [a / i, 1];
}
function size() {
var dpr = Math.min(window.devicePixelRatio || 1, 1.5);
var w = Math.max(1, Math.round(wrap.clientWidth * dpr)), h = Math.max(1, Math.round(wrap.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; if (gl) gl.viewport(0, 0, w, h); }
}
function drawStill() {
size();
var s = cover(canvas.width, canvas.height), sw = art.width * s[0], sh2 = art.height * s[1];
ctx2.drawImage(art, (art.width - sw) / 2, (art.height - sh2) / 2, sw, sh2, 0, 0, canvas.width, canvas.height);
}
function draw(t, flat) {
size();
var s = cover(canvas.width, canvas.height);
gl.uniform1f(u.uT, t);
gl.uniform1f(u.uAmp, flat ? 0 : opts.strength * 0.6);
gl.uniform1f(u.uFreq, opts.frequency);
gl.uniform1f(u.uE, flat ? 0 : energy * opts.strength);
gl.uniform1f(u.uAsp, canvas.width / canvas.height);
gl.uniform2f(u.uM, mx, my);
gl.uniform2f(u.uS, s[0], s[1]);
gl.drawElements(gl.TRIANGLES, nIdx, gl.UNSIGNED_SHORT, 0);
}
function loop(now) {
raf = requestAnimationFrame(loop);
if (!visible || document.hidden) return;
mx += (tx - mx) * 0.14; my += (ty - my) * 0.14;
energy *= 0.95;
draw(((now - t0) / 1000) * opts.speed, false);
}
function onMove(e) {
var r = wrap.getBoundingClientRect();
var nx = (e.clientX - r.left) / r.width * 2 - 1, ny = -((e.clientY - r.top) / r.height * 2 - 1);
var now = performance.now();
if (lastX !== null) {
var dt = Math.max(8, now - lastT), sp = Math.hypot(nx - lastX, ny - lastY) / dt * 16;
energy = Math.min(1.2, Math.max(energy, sp * 2.2));
}
lastX = nx; lastY = ny; lastT = now; tx = nx; ty = ny;
}
function onLeave() { lastX = null; }
function update(next) {
var prev = opts;
opts = Object.assign({ strength: 0.6, frequency: 5, speed: 1, colorA: "#005236", colorB: "#e6ff55" }, opts, next || {});
wrap.style.setProperty("--ef-wa", opts.colorA);
if (!art || prev.colorA !== opts.colorA || prev.colorB !== opts.colorB) { art = makeArt(); if (gl) upload(); }
if (destroyed) return;
if (reduce) { if (gl) draw(0, true); else if (ctx2) drawStill(); }
else if (!gl && ctx2) drawStill();
}
gl = canvas.getContext("webgl", { antialias: false, alpha: false, powerPreference: "low-power" });
var ok = !!gl && setup();
if (!ok) {
if (gl) { var c2 = canvas.cloneNode(false); canvas.parentNode.replaceChild(c2, canvas); canvas = c2; gl = null; }
ctx2 = canvas.getContext("2d");
}
update(o);
if (ok) {
upload();
if (!reduce) {
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerleave", onLeave);
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
raf = requestAnimationFrame(loop);
} else draw(0, true);
}
if ("ResizeObserver" in window) {
ro = new ResizeObserver(function () { if (destroyed) return; if (!ok && ctx2) drawStill(); else if (ok && reduce) draw(0, true); });
ro.observe(wrap);
}
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
wrap.removeEventListener("pointermove", onMove); wrap.removeEventListener("pointerleave", onLeave);
if (io) io.disconnect();
if (ro) ro.disconnect();
if (gl) { var ext = gl.getExtension("WEBGL_lose_context"); if (tex) gl.deleteTexture(tex); if (prog) gl.deleteProgram(prog); if (ext) ext.loseContext(); }
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"strength":0.6,"frequency":5,"speed":1,"colorA":"#005236","colorB":"#e6ff55"});How it works
The artwork is painted once on an offscreen canvas and uploaded as a WebGL texture. A grid of 72 by 48 vertices is displaced in the vertex shader by three summed sine waves, plus a radial ripple centred on the smoothed pointer position. The ripple height follows pointer speed and decays when you stop. The wave height also tilts the texture lookup and brightens the crests, which sells the surface without any extra geometry.
Accessibility and performance
The canvas has a text label and the effect is decorative. With prefers-reduced-motion the picture is drawn flat and never animates, and pointer movement is ignored. If WebGL is missing the same artwork is drawn on a plain 2D canvas.
One draw call of about 7,000 triangles per frame, a capped pixel ratio of 1.5, no antialiasing and a low-power GPU hint. The loop pauses when the element is off screen or the tab is hidden, and the WebGL context is released on destroy.
| Section | Hero |
|---|---|
| Techniques | WebGL, Pointer interaction |
| Stack | Vanilla JS, WebGL, Canvas |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Create a Wave Motion Effect on an Image with Three.js by Mario Carrillo on Codrops. 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.
More elements
FAQ
Can I use my own photo?
Yes. Replace the makeArt function with code that draws your image onto the same canvas, or load an image from a host that allows cross-origin use and upload it as the texture.
Why does the picture look cropped on wide screens?
The shader uses cover scaling, so a very wide container shows a horizontal slice of the artwork. Make the container taller or change the focus point in the vertex shader.
Does it work on touch screens?
Yes. Dragging a finger counts as pointer movement, and vertical page scrolling still works because the element only claims horizontal gestures.