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 "Audio pulse distortion" for my site using the Enrichr Library element at https://enrichr.dev/elements/audio-pulse-distortion.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/audio-pulse-distortion.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:
- bpm: 110
- intensity: 1
- mic: 0
- 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/audio-pulse-distortion.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ap">
<canvas class="ef-ap__c" role="img" aria-label="A generated record artwork that pulses and distorts in time with a beat"></canvas>
<div class="ef-ap__bars" aria-hidden="true"></div>
<div class="ef-ap__side">
<button type="button" class="ef-ap__mic" aria-pressed="false">Use microphone</button>
<span class="ef-ap__status" role="status" aria-live="polite"></span>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ap { position: relative; width: 100%; min-height: 20rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-ap__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; transform-origin: 50% 50%; will-change: transform; }
.ef-ap__bars { position: absolute; left: 0.9rem; bottom: 0.8rem; height: 1.8rem; display: flex; align-items: flex-end; gap: 3px; pointer-events: none; }
.ef-ap__bars i { display: block; width: 4px; height: 100%; background: var(--ef-ap-accent, #e6ff55); border-radius: 1px; transform-origin: 50% 100%; transform: scaleY(0.08); }
.ef-ap__side { position: absolute; right: 0.8rem; top: 0.8rem; display: flex; flex-direction: column; align-items: flex-end; gap: 0.35rem; max-width: calc(100% - 1.6rem); }
.ef-ap__mic { padding: 0.4rem 0.8rem; border-radius: 999px; border: 1.5px solid rgba(251, 248, 241, 0.7); background: rgba(12, 30, 24, 0.6); color: #fbf8f1; font: inherit; font-size: 0.75rem; letter-spacing: 0.04em; cursor: pointer; }
.ef-ap__mic:hover:not(:disabled) { background: var(--ef-ap-accent, #e6ff55); border-color: var(--ef-ap-accent, #e6ff55); color: #0c1e18; }
.ef-ap__mic[aria-pressed="true"] { background: var(--ef-ap-accent, #e6ff55); border-color: var(--ef-ap-accent, #e6ff55); color: #0c1e18; }
.ef-ap__mic:disabled { opacity: 0.5; cursor: default; }
.ef-ap__mic:focus-visible { outline: 2px solid #fbf8f1; outline-offset: 2px; }
.ef-ap__status { font-size: 0.7rem; color: #fbf8f1; background: rgba(12, 30, 24, 0.6); padding: 0.15rem 0.5rem; border-radius: 0.5rem; text-align: right; }
.ef-ap__status:empty { display: none; }JavaScript
// Enrichr Library · https://enrichr.dev
// Audio pulse distortion: a generated record artwork is drawn by a WebGL shader that zooms, ripples and splits its colour channels in time with an audio-like signal.
// By default the signal is a built-in synthetic beat, so nothing is recorded or downloaded. Pressing "Use microphone" (or setting mic to 1) asks the browser for the mic only then; if it is refused the beat keeps playing.
// Nothing is played through the speakers. Without WebGL the artwork is drawn flat and pulses with a CSS scale; reduced motion shows the still artwork with no pulse.
// mount(root, { bpm (60-180), intensity (0-2), mic (0 or 1, starts the microphone only after a click or update), colorA (artwork green), colorB (artwork accent) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-ap");
var canvas = root.querySelector(".ef-ap__c");
var barsEl = root.querySelector(".ef-ap__bars");
var micBtn = root.querySelector(".ef-ap__mic");
var statusEl = root.querySelector(".ef-ap__status");
var opts = {}, gl = null, ctx2 = null, prog, u = {}, tex, art = null, raf = 0, visible = true, io = null, ro = null, destroyed = false;
var t0 = performance.now(), bars = [], lv = { bass: 0, mid: 0, tre: 0, k: 9, bins: new Float32Array(16) }, source = "synth";
var audio = null, micToken = 0, lastKick = 0, prevBass = 0, peak = 0.2;
var VS = "attribute vec2 p;varying vec2 vUv;void main(){vUv=p*.5+.5;gl_Position=vec4(p,0.,1.);}";
var FS = "precision mediump float;varying vec2 vUv;uniform sampler2D uT;uniform float uTime,uBass,uMid,uTre,uK,uI,uAsp;uniform vec2 uS;" +
"void main(){vec2 c=vUv-.5;vec2 p=c*vec2(uAsp,1.);float r=length(p);vec2 dir=p/(r+.0001);" +
"float zoom=1.-uBass*.07*uI;float rad=uK*1.1;" +
"float ring=exp(-pow((r-rad)*10.,2.))*exp(-uK*2.6)*uI;" +
"vec2 q=c*zoom-dir*ring*.05/vec2(uAsp,1.);" +
"q.x+=sin(p.y*22.+uTime*6.)*uTre*.008*uI;" +
"float ca=(.003*uI+uTre*.012*uI+ring*.012);vec2 off=dir/vec2(uAsp,1.)*ca;" +
"vec2 b=q*uS+.5;" +
"vec3 col=vec3(texture2D(uT,clamp(b+off,0.,1.)).r,texture2D(uT,clamp(b,0.,1.)).g,texture2D(uT,clamp(b-off,0.,1.)).b);" +
"col*=1.+uBass*.1*uI+ring*.25;col+=vec3(uMid*.03*uI);" +
"col*=1.-.5*smoothstep(.5,1.1,r);gl_FragColor=vec4(col,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 rgba(c, a) { return "rgba(" + c.join(",") + "," + a + ")"; }
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], rnd = seeded(31), i;
var cx = W / 2, cy = H / 2, R = H * 0.46;
var bg = g.createRadialGradient(cx, cy, 20, cx, cy, W * 0.6); bg.addColorStop(0, mix(A, D, 0.2)); bg.addColorStop(1, mix(D, A, 0.08));
g.fillStyle = bg; g.fillRect(0, 0, W, H);
var N = 28;
for (i = 0; i < N; i++) {
var a0 = i / N * 6.2832, a1 = (i + 0.5) / N * 6.2832;
g.beginPath(); g.moveTo(cx, cy); g.arc(cx, cy, W, a0, a1); g.closePath();
g.fillStyle = i % 2 ? rgba(A, 0.55) : rgba(D, 0.35); g.fill();
}
g.fillStyle = "rgba(0,0,0,.35)"; g.beginPath(); g.arc(cx + 10, cy + 16, R + 6, 0, 7); g.fill();
g.fillStyle = "#07120e"; g.beginPath(); g.arc(cx, cy, R, 0, 7); g.fill();
g.lineWidth = 1;
for (var r = R * 0.38; r < R - 3; r += 4.2) { g.strokeStyle = rgba(C, 0.04 + rnd() * 0.07); g.beginPath(); g.arc(cx, cy, r, 0, 7); g.stroke(); }
var sheen = g.createConicGradient ? g.createConicGradient(0.6, cx, cy) : null;
if (sheen) {
sheen.addColorStop(0, "rgba(255,255,255,0)"); sheen.addColorStop(0.08, "rgba(255,255,255,.2)"); sheen.addColorStop(0.16, "rgba(255,255,255,0)");
sheen.addColorStop(0.5, "rgba(255,255,255,0)"); sheen.addColorStop(0.58, "rgba(255,255,255,.2)"); sheen.addColorStop(0.66, "rgba(255,255,255,0)"); sheen.addColorStop(1, "rgba(255,255,255,0)");
g.fillStyle = sheen; g.beginPath(); g.arc(cx, cy, R, 0, 7); g.arc(cx, cy, R * 0.38, 0, 7, true); g.fill("evenodd");
}
g.strokeStyle = rgba(C, 0.55); g.lineWidth = 2; g.beginPath(); g.arc(cx, cy, R, 0, 7); g.stroke();
var lab = g.createRadialGradient(cx, cy, 6, cx, cy, R * 0.34); lab.addColorStop(0, mix(B, D, 0.12)); lab.addColorStop(1, mix(B, D, 0.38));
g.fillStyle = lab; g.beginPath(); g.arc(cx, cy, R * 0.34, 0, 7); g.fill();
g.strokeStyle = rgba(D, 0.7); g.lineWidth = 3;
g.beginPath(); g.arc(cx, cy, R * 0.26, 0, 7); g.stroke();
g.fillStyle = rgba(D, 0.8);
for (i = 0; i < 24; i++) { var ta = i / 24 * 6.2832; g.beginPath(); g.arc(cx + Math.cos(ta) * R * 0.3, cy + Math.sin(ta) * R * 0.3, 3, 0, 7); g.fill(); }
g.fillRect(cx - R * 0.12, cy + R * 0.12, R * 0.24, 5); g.fillRect(cx - R * 0.08, cy + R * 0.17, R * 0.16, 5);
g.fillStyle = "#07120e"; g.beginPath(); g.arc(cx, cy, 9, 0, 7); g.fill();
g.fillStyle = "rgba(255,255,255,.03)";
for (i = 0; i < 7000; i++) g.fillRect(rnd() * W, rnd() * H, 1.5, 1.5);
return c;
}
// built-in beat: kicks, snare and off-beat hats on a four beat bar, with a gentle moving pad underneath
var KICKS = [0, 1, 2, 2.75, 3], SNARES = [1, 3], HATS = [0.5, 1.5, 2.5, 3.5, 3.75];
function since(list, tb, beat, bar) {
var best = 99;
for (var i = 0; i < list.length; i++) { var d = (tb - list[i] * beat + bar) % bar; if (d < best) best = d; }
return best;
}
function synth(t) {
var beat = 60 / opts.bpm, bar = beat * 4, tb = t % bar;
var dk = since(KICKS, tb, beat, bar), ds = since(SNARES, tb, beat, bar), dh = since(HATS, tb, beat, bar);
var bass = Math.exp(-dk * 7.5), mid = Math.exp(-ds * 9), tre = Math.exp(-dh * 20);
lv.bass = bass; lv.mid = mid; lv.tre = tre; lv.k = dk;
for (var i = 0; i < 16; i++) {
var f = i / 15;
var v = bass * Math.pow(1 - f, 1.6) + mid * Math.exp(-Math.pow((f - 0.42) * 3.2, 2)) * 0.9 + tre * Math.pow(f, 1.2) * 0.85 + 0.07 * (Math.sin(t * 2.2 + i * 0.9) + 1) * (1 - f * 0.5);
lv.bins[i] = Math.min(1, v);
}
}
function listen(now) {
audio.an.getByteFrequencyData(audio.data);
var d = audio.data, i, j, s;
function avg(a, b) { var t = 0; for (var q = a; q <= b; q++) t += d[q]; return t / (b - a + 1) / 255; }
var bass = avg(1, 3), mid = avg(4, 20), tre = avg(21, 70);
peak = Math.max(peak * 0.997, bass, mid, tre, 0.12);
bass = Math.min(1, bass / peak); mid = Math.min(1, mid / peak); tre = Math.min(1, tre / peak);
if (bass > 0.55 && bass > prevBass * 1.12 && now - lastKick > 220) lastKick = now;
prevBass = bass * 0.5 + prevBass * 0.5;
lv.bass = bass; lv.mid = mid; lv.tre = tre; lv.k = (now - lastKick) / 1000;
for (i = 0; i < 16; i++) {
var a = Math.round(Math.pow(i / 16, 1.7) * 90) + 1, b = Math.max(a, Math.round(Math.pow((i + 1) / 16, 1.7) * 90) + 1);
s = 0; for (j = a; j <= b; j++) s += d[j]; lv.bins[i] = Math.min(1, s / (b - a + 1) / 255 / peak);
}
}
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);
["uT", "uTime", "uBass", "uMid", "uTre", "uK", "uI", "uAsp", "uS"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
tex = gl.createTexture(); gl.uniform1i(u.uT, 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 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 cover() { var a = canvas.width / canvas.height, i = art.width / art.height; return a > i ? [1, i / a] : [a / i, 1]; }
function draw(t, still) {
size();
var s = cover();
if (gl) {
gl.uniform1f(u.uTime, t); gl.uniform1f(u.uBass, still ? 0 : lv.bass); gl.uniform1f(u.uMid, still ? 0 : lv.mid); gl.uniform1f(u.uTre, still ? 0 : lv.tre);
gl.uniform1f(u.uK, still ? 9 : lv.k); gl.uniform1f(u.uI, still ? 0 : opts.intensity); gl.uniform1f(u.uAsp, canvas.width / canvas.height); gl.uniform2f(u.uS, s[0], s[1]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
} else if (ctx2) {
var 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);
canvas.style.transform = still ? "none" : "scale(" + (1 + lv.bass * 0.05 * opts.intensity).toFixed(4) + ")";
}
}
function paintBars(still) {
for (var i = 0; i < 16; i++) bars[i].style.transform = "scaleY(" + (still ? 0.08 : Math.max(0.08, lv.bins[i])).toFixed(3) + ")";
}
function loop(now) {
raf = requestAnimationFrame(loop);
if (!visible || document.hidden) return;
if (source === "mic" && audio) listen(now); else synth((now - t0) / 1000);
draw((now - t0) / 1000, false); paintBars(false);
}
function say(msg) { statusEl.textContent = msg; }
function stopMic() {
micToken++;
if (audio) { try { audio.stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} try { audio.ctx.close(); } catch (e2) {} audio = null; }
source = "synth"; micBtn.setAttribute("aria-pressed", "false");
}
function startMic() {
if (reduce || destroyed) return;
var md = navigator.mediaDevices, AC = window.AudioContext || window.webkitAudioContext;
if (!md || !md.getUserMedia || !AC) { opts.mic = 0; say("Microphone not available here. Using the built-in beat."); return; }
var token = ++micToken; say("Waiting for microphone permission...");
md.getUserMedia({ audio: true, video: false }).then(function (stream) {
if (token !== micToken || destroyed) { stream.getTracks().forEach(function (t) { t.stop(); }); return; }
var ctx = new AC(), an = ctx.createAnalyser(); an.fftSize = 256; an.smoothingTimeConstant = 0.6;
ctx.createMediaStreamSource(stream).connect(an); // not connected to the speakers, so no feedback
if (ctx.state === "suspended" && ctx.resume) ctx.resume();
audio = { stream: stream, ctx: ctx, an: an, data: new Uint8Array(an.frequencyBinCount) };
peak = 0.2; lastKick = performance.now(); source = "mic";
micBtn.setAttribute("aria-pressed", "true"); micBtn.textContent = "Use built-in beat"; say("Listening to your microphone. Nothing is recorded or sent.");
}).catch(function () {
if (token !== micToken) return;
opts.mic = 0; source = "synth"; micBtn.setAttribute("aria-pressed", "false"); micBtn.textContent = "Use microphone";
say("Microphone blocked or unavailable. Using the built-in beat.");
});
}
function applyMic() {
if (+opts.mic === 1) { if (source !== "mic" && !audio) startMic(); }
else if (source === "mic" || audio) { stopMic(); micBtn.textContent = "Use microphone"; say("Using the built-in beat."); }
}
function onMicClick() {
opts.mic = (+opts.mic === 1) ? 0 : 1;
applyMic();
}
function update(partial) {
var prev = opts;
opts = Object.assign({ bpm: 110, intensity: 1, mic: 0, colorA: "#005236", colorB: "#e6ff55" }, opts, partial || {});
wrap.style.setProperty("--ef-ap-accent", opts.colorB);
if (!art || prev.colorA !== opts.colorA || prev.colorB !== opts.colorB) { art = makeArt(); upload(); }
if (destroyed) return;
if (partial && "mic" in partial) applyMic();
if (reduce) { draw(0, true); paintBars(true); }
}
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");
}
for (var i = 0; i < 16; i++) { var b = document.createElement("i"); barsEl.appendChild(b); bars.push(b); }
// a mic value at mount time is ignored: the microphone starts only after a click or a later update
update(Object.assign({}, o || {}, { mic: 0 }));
opts.mic = 0;
micBtn.addEventListener("click", onMicClick);
if (reduce) {
micBtn.disabled = true; say("Reduced motion is on, so the pulse is paused.");
ro = "ResizeObserver" in window ? new ResizeObserver(function () { if (!destroyed) { draw(0, true); } }) : null;
if (ro) ro.observe(wrap);
} else {
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
raf = requestAnimationFrame(loop);
}
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf); stopMic();
micBtn.removeEventListener("click", onMicClick);
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"), {"bpm":110,"intensity":1,"mic":0,"colorA":"#005236","colorB":"#e6ff55"});How it works
A small signal generator produces bass, mid and treble levels and 16 spectrum bins from a four-beat pattern of kicks, snares and hats, so the element works with no audio at all. A fragment shader reads those levels: bass zooms the picture, each kick sends a shock ring outward that pushes pixels, and treble adds a wobble and splits the red, green and blue channels. If the microphone is switched on, an analyser node supplies the same three levels and bins instead, with automatic gain so quiet rooms still move it.
Accessibility and performance
The microphone is never requested on load, only after a click on the button or a later update call, and the status line says what is happening, including when permission is refused. Audio is analysed only and never played, recorded or sent anywhere. Reduced motion shows the still artwork and disables the microphone button. The zoom and colour split get strong at high intensity, so keep the default or lower it on public pages.
One full-screen triangle with three texture reads per pixel, a capped pixel ratio of 1.5 and a loop that pauses off screen. The analyser uses 128 bins. The audio context, tracks and WebGL context are all released on destroy.
| Section | Hero |
|---|---|
| Techniques | WebGL |
| Stack | Vanilla JS, WebGL, Web Audio |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Audio-based Image Distortion Effects with WebGL by Yannis Yannakopoulos 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
Does it record my voice?
No. The analyser only reads loudness by frequency band, nothing is stored or uploaded, and the audio is not routed to the speakers.
What happens if I block the microphone?
The status line says so and the built-in beat keeps going.
Can it react to a track on the page?
Yes. Create an analyser from a media element source and feed its bins into the same level variables that the microphone path writes.