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 "WebGL image transition" for my site using the Enrichr Library element at https://enrichr.dev/elements/webgl-image-transition.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/webgl-image-transition.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:
- style: dissolve
- duration: 1400
- auto: 1
- interval: 4
- accent: #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/webgl-image-transition.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-wit" tabindex="0" role="group" aria-roledescription="carousel" aria-label="Generated artwork slides with WebGL transitions. Use the arrow keys to change slide.">
<canvas class="ef-wit__c" aria-hidden="true"></canvas>
<div class="ef-wit__ui">
<div class="ef-wit__cap" aria-live="polite"></div>
<div class="ef-wit__nav">
<div class="ef-wit__dots"></div>
<button type="button" class="ef-wit__btn" data-dir="-1" aria-label="Previous slide"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<button type="button" class="ef-wit__btn" data-dir="1" aria-label="Next slide"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-wit { position: relative; width: 100%; min-height: 20rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; outline: none; cursor: pointer; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-wit:focus-visible { box-shadow: inset 0 0 0 3px #e6ff55; }
.ef-wit__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-wit__ui { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: flex-end; justify-content: space-between; gap: 0.5rem; padding: 2.4rem 0.9rem 0.8rem; background: linear-gradient(to top, rgba(12, 30, 24, 0.78), rgba(12, 30, 24, 0)); color: #fbf8f1; pointer-events: none; }
.ef-wit__cap { font-size: clamp(0.8rem, 2.6vw, 1.15rem); font-weight: 700; letter-spacing: 0.02em; line-height: 1.1; min-width: 0; }
.ef-wit__cap small { display: block; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.68em; font-weight: 500; opacity: 0.7; margin-bottom: 0.25rem; }
.ef-wit__nav { display: flex; align-items: center; gap: 0.4rem; pointer-events: auto; }
.ef-wit__dots { display: flex; gap: 0.3rem; margin-right: 0.3rem; }
.ef-wit__dot { width: 0.55rem; height: 0.55rem; padding: 0; border-radius: 50%; border: 1.5px solid #fbf8f1; background: transparent; cursor: pointer; }
.ef-wit__dot[aria-current="true"] { background: var(--ef-wit-accent, #e6ff55); border-color: var(--ef-wit-accent, #e6ff55); }
.ef-wit__btn { display: grid; place-items: center; width: 2.1rem; height: 2.1rem; padding: 0; border-radius: 50%; border: 1.5px solid rgba(251, 248, 241, 0.7); background: rgba(12, 30, 24, 0.45); color: #fbf8f1; cursor: pointer; }
.ef-wit__btn:hover { background: var(--ef-wit-accent, #e6ff55); border-color: var(--ef-wit-accent, #e6ff55); color: #0c1e18; }
.ef-wit__btn:focus-visible, .ef-wit__dot:focus-visible { outline: 2px solid #fbf8f1; outline-offset: 2px; }JavaScript
// Enrichr Library · https://enrichr.dev
// WebGL image transition: three generated artworks (painted on canvases, no image files) switch with a raw WebGL fragment shader. Click, buttons, arrow keys or autoplay move between slides.
// Styles: dissolve (noise-driven burn with an accent edge), wipe-warp (a wavy wipe that bends the picture), pixel (blocks grow, swap cell by cell, then sharpen). Without WebGL it crossfades on a 2D canvas; reduced motion swaps instantly and never autoplays.
// mount(root, { style ("dissolve" | "wipe-warp" | "pixel"), duration (ms), auto (1 or 0), interval (seconds), accent (colour) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-wit");
var canvas = root.querySelector(".ef-wit__c");
var cap = root.querySelector(".ef-wit__cap");
var dotsEl = root.querySelector(".ef-wit__dots");
var btns = root.querySelectorAll(".ef-wit__btn");
var opts = {}, gl = null, ctx2 = null, prog, u = {}, texs = [], slides = [], dots = [];
var cur = 0, anim = null, raf = 0, timer = 0, visible = true, hovering = false, io = null, ro = null, destroyed = false;
var NAMES = ["Ridge", "Orbit", "Lattice"], STYLES = { dissolve: 0, "wipe-warp": 1, pixel: 2 };
var VS = "attribute vec2 p;uniform vec2 uS;varying vec2 vUv;void main(){vUv=p*.5*uS+.5;gl_Position=vec4(p,0.,1.);}";
var FS = "precision mediump float;varying vec2 vUv;uniform sampler2D uA,uB;uniform float uP,uStyle,uAsp;uniform vec3 uAcc;" +
"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;}" +
"vec3 tA(vec2 q){return texture2D(uA,clamp(q,0.,1.)).rgb;}" +
"vec3 tB(vec2 q){return texture2D(uB,clamp(q,0.,1.)).rgb;}" +
"void main(){vec2 uv=vUv;float p=uP;vec3 col;" +
"if(uStyle<.5){" +
"vec2 q=uv*vec2(uAsp,1.)*3.;float nz=fbm(q);" +
"vec2 d=(vec2(fbm(q*1.3+3.),fbm(q*1.3-2.))-.5)*.2;" +
"float m=smoothstep(nz-.14,nz+.14,p*1.28-.14);" +
"col=mix(tA(uv+d*p),tB(uv-d*(1.-p)),m);" +
"float e=m*(1.-m)*4.;col+=uAcc*e*e*.7;" +
"}else if(uStyle<1.5){" +
"float w=sin(uv.y*7.+p*6.)*.06+sin(uv.y*19.-p*4.)*.012;" +
"float e=p*1.3-.15;float x=uv.x+w-e;" +
"float m=1.-smoothstep(-.02,.02,x);" +
"float k=exp(-x*x*70.);" +
"col=mix(tA(uv+vec2(k*.05,0.)),tB(uv-vec2(k*.05,0.)),m);" +
"col+=uAcc*exp(-x*x*3000.)*.9;" +
"}else{" +
"float f=sin(p*3.14159);float cells=mix(500.,16.,pow(f,.55));" +
"vec2 g=vec2(cells*uAsp,cells);vec2 id=floor(uv*g);vec2 q=(id+.5)/g;" +
"float m=step(h(id*.37+7.),p);" +
"col=mix(tA(q),tB(q),m);col+=uAcc*f*f*.07;" +
"}" +
"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 css(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + (a === undefined ? 1 : a) + ")"; }
function seeded(s) { return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function paint(i) {
var W = 1280, H = 800, c = document.createElement("canvas"); c.width = W; c.height = H;
var g = c.getContext("2d"), ACC = hex(opts.accent, "e6ff55"), D = [12, 30, 24], G = [0, 82, 54], C = [251, 248, 241], rnd = seeded(11 + i * 5), x, y, k;
if (i === 0) {
var bg = g.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, css(D)); bg.addColorStop(1, css(G));
g.fillStyle = bg; g.fillRect(0, 0, W, H);
var sx = W * 0.3, sy = H * 0.4, sr = H * 0.26;
var gl1 = g.createRadialGradient(sx, sy, sr * 0.3, sx, sy, sr * 2.8); gl1.addColorStop(0, css(ACC, 0.4)); gl1.addColorStop(1, css(ACC, 0));
g.fillStyle = gl1; g.fillRect(0, 0, W, H);
g.fillStyle = css(ACC); g.beginPath(); g.arc(sx, sy, sr, 0, 7); g.fill();
g.fillStyle = css(D, 0.85);
for (k = 0; k < 6; k++) g.fillRect(sx - sr, sy + sr * 0.1 + k * sr * 0.15, sr * 2, 2 + k * 2.4);
for (k = 0; k < 4; k++) {
var base = H * (0.6 + k * 0.1), amp = 40 + k * 8, ph = rnd() * 6;
g.beginPath(); g.moveTo(0, H);
for (x = 0; x <= W; x += 8) g.lineTo(x, base + Math.sin(x * (0.005 + k * 0.0012) + ph) * amp + Math.sin(x * 0.013 + ph) * 12);
g.lineTo(W, H); g.closePath();
g.fillStyle = "rgb(" + [Math.round(G[0] * (1 - k * 0.2)), Math.round(G[1] * (1 - k * 0.2)), Math.round(G[2] * (1 - k * 0.2) + 8)].join(",") + ")"; g.fill();
g.strokeStyle = css(C, 0.12 + k * 0.05); g.lineWidth = 2; g.stroke();
}
} else if (i === 1) {
g.fillStyle = css(C); g.fillRect(0, 0, W, H);
var cx = W * 0.62, cy = H * 0.46;
for (k = 9; k >= 1; k--) {
g.beginPath(); g.arc(cx, cy, k * 52, 0, 7);
g.fillStyle = k % 2 ? css(G) : css(C); g.fill();
g.strokeStyle = css(D, 0.5); g.lineWidth = 1.5; g.stroke();
}
g.fillStyle = css(D); g.beginPath(); g.arc(cx, cy, 70, 0, 7); g.fill();
g.fillStyle = css(ACC); g.beginPath(); g.arc(cx, cy, 34, 0, 7); g.fill();
for (k = 0; k < 5; k++) {
var a = k * 1.25 + 0.5, rr = 104 + k * 104;
g.fillStyle = css(k % 2 ? D : ACC); g.strokeStyle = css(D); g.lineWidth = 3;
g.beginPath(); g.arc(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, 17, 0, 7); g.fill(); g.stroke();
}
g.fillStyle = css(D, 0.9); g.fillRect(80, 100, 150, 8); g.fillRect(80, 124, 90, 8);
} else {
g.fillStyle = css(G); g.fillRect(0, 0, W, H);
g.save(); g.translate(W * 0.5, H * 0.5); g.rotate(-0.42);
for (k = -14; k < 14; k++) { g.fillStyle = k % 3 === 0 ? css(ACC) : k % 3 === 1 ? css(D) : css(G, 1); if (k % 3 === 2) g.fillStyle = "rgb(0,64,42)"; g.fillRect(-W, k * 64, W * 2, 44); }
g.restore();
g.fillStyle = css(D); g.beginPath(); g.arc(W * 0.7, H * 0.4, 190, 0, 7); g.fill();
g.fillStyle = css(C); g.beginPath(); g.moveTo(W * 0.7 - 110, H * 0.4 + 90); g.lineTo(W * 0.7, H * 0.4 - 120); g.lineTo(W * 0.7 + 110, H * 0.4 + 90); g.closePath(); g.fill();
g.fillStyle = css(ACC); g.beginPath(); g.arc(W * 0.7, H * 0.4 + 12, 38, 0, 7); g.fill();
g.fillStyle = css(C, 0.9);
for (x = 0; x < 6; x++) for (y = 0; y < 4; y++) { g.beginPath(); g.arc(120 + x * 44, 520 + y * 44, 6, 0, 7); g.fill(); }
}
g.fillStyle = "rgba(255,255,255,.03)";
for (k = 0; k < 6000; k++) 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 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);
["uS", "uA", "uB", "uP", "uStyle", "uAsp", "uAcc"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
gl.uniform1i(u.uA, 0); gl.uniform1i(u.uB, 1);
for (var i = 0; i < 3; i++) texs.push(gl.createTexture());
return true;
}
function upload() {
if (!gl) return;
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
texs.forEach(function (t, i) {
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, slides[i]);
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 = 1280 / 800;
return a > i ? [1, i / a] : [a / i, 1];
}
function render(from, to, p) {
size();
if (gl) {
var s = cover(), acc = hex(opts.accent, "e6ff55");
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texs[from]);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texs[to]);
gl.uniform2f(u.uS, s[0], s[1]);
gl.uniform1f(u.uP, p);
gl.uniform1f(u.uStyle, STYLES[opts.style] === undefined ? 0 : STYLES[opts.style]);
gl.uniform1f(u.uAsp, canvas.width / canvas.height);
gl.uniform3f(u.uAcc, acc[0] / 255, acc[1] / 255, acc[2] / 255);
gl.drawArrays(gl.TRIANGLES, 0, 3);
} else if (ctx2) {
var s2 = cover(), sw = 1280 * s2[0], sh2 = 800 * s2[1];
ctx2.globalAlpha = 1;
ctx2.drawImage(slides[from], (1280 - sw) / 2, (800 - sh2) / 2, sw, sh2, 0, 0, canvas.width, canvas.height);
if (p > 0) { ctx2.globalAlpha = p; ctx2.drawImage(slides[to], (1280 - sw) / 2, (800 - sh2) / 2, sw, sh2, 0, 0, canvas.width, canvas.height); ctx2.globalAlpha = 1; }
}
}
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function label() {
cap.innerHTML = "";
var s = document.createElement("small"); s.textContent = "0" + (cur + 1) + " / 03";
cap.appendChild(s); cap.appendChild(document.createTextNode(NAMES[cur]));
dots.forEach(function (d, i) { d.setAttribute("aria-current", i === cur ? "true" : "false"); });
}
function frame(now) {
if (!anim) { raf = 0; return; }
var t = Math.min(1, (now - anim.t0) / Math.max(1, opts.duration));
render(anim.from, anim.to, ease(t));
if (t >= 1) { render(anim.to, anim.to, 0); anim = null; raf = 0; return; }
raf = requestAnimationFrame(frame);
}
function go(to) {
to = (to + 3) % 3;
if (to === cur || destroyed) return;
if (anim) { cur = anim.to; anim = null; cancelAnimationFrame(raf); raf = 0; }
var from = cur; cur = to; label();
if (reduce || !opts.duration) { render(cur, cur, 0); return; }
anim = { from: from, to: to, t0: performance.now() };
raf = requestAnimationFrame(frame);
}
function schedule() {
clearTimeout(timer); timer = 0;
if (reduce || destroyed || !(+opts.auto === 1)) return;
timer = setTimeout(function () {
if (visible && !hovering && !document.hidden && !wrap.contains(document.activeElement)) go(cur + 1);
schedule();
}, Math.max(1, opts.interval) * 1000);
}
function onClick(e) { if (e.target.closest(".ef-wit__btn") || e.target.closest(".ef-wit__dot")) return; go(cur + 1); schedule(); }
function onKey(e) {
if (e.key === "ArrowRight") { go(cur + 1); schedule(); e.preventDefault(); }
else if (e.key === "ArrowLeft") { go(cur - 1); schedule(); e.preventDefault(); }
}
function onBtn(e) { go(cur + Number(e.currentTarget.getAttribute("data-dir"))); schedule(); }
function onEnter() { hovering = true; }
function onLeave() { hovering = false; }
function update(next) {
var prev = opts;
opts = Object.assign({ style: "dissolve", duration: 1400, auto: 1, interval: 4, accent: "#e6ff55" }, opts, next || {});
wrap.style.setProperty("--ef-wit-accent", opts.accent);
if (!slides.length || prev.accent !== opts.accent) { slides = [paint(0), paint(1), paint(2)]; upload(); }
if (destroyed) return;
if (!anim) render(cur, cur, 0);
schedule();
}
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 < 3; i++) {
var d = document.createElement("button"); d.type = "button"; d.className = "ef-wit__dot";
d.setAttribute("aria-label", "Go to slide " + (i + 1) + ", " + NAMES[i]);
(function (idx) { d.addEventListener("click", function () { go(idx); schedule(); }); })(i);
dotsEl.appendChild(d); dots.push(d);
}
update(o);
label();
wrap.addEventListener("click", onClick);
wrap.addEventListener("keydown", onKey);
wrap.addEventListener("pointerenter", onEnter);
wrap.addEventListener("pointerleave", onLeave);
Array.prototype.forEach.call(btns, function (b) { b.addEventListener("click", onBtn); });
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed && !anim) render(cur, cur, 0); }); ro.observe(wrap); }
return {
update: update,
destroy: function () {
destroyed = true; clearTimeout(timer); cancelAnimationFrame(raf); anim = null;
wrap.removeEventListener("click", onClick); wrap.removeEventListener("keydown", onKey);
wrap.removeEventListener("pointerenter", onEnter); wrap.removeEventListener("pointerleave", onLeave);
Array.prototype.forEach.call(btns, function (b) { b.removeEventListener("click", onBtn); });
if (io) io.disconnect();
if (ro) ro.disconnect();
if (gl) { var ext = gl.getExtension("WEBGL_lose_context"); texs.forEach(function (t) { gl.deleteTexture(t); }); if (prog) gl.deleteProgram(prog); if (ext) ext.loseContext(); }
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"style":"dissolve","duration":1400,"auto":1,"interval":4,"accent":"#e6ff55"});How it works
Each slide is painted on a canvas and uploaded as a texture. One fragment shader receives the outgoing and incoming textures plus a progress value from 0 to 1, eased on the JavaScript side. Dissolve uses layered noise as a threshold with a glowing edge, wipe-warp moves a wavy vertical edge across while bending both pictures near it, and pixel snaps the image to a coarse grid and swaps cells at random. The loop only runs during a transition, so an idle slide costs nothing.
Accessibility and performance
The group is focusable and announces itself as a carousel. Left and right arrow keys, the previous and next buttons and the dots all change slide, the caption is a polite live region, and autoplay pauses on hover, on focus and when off screen. Reduced motion swaps slides instantly and turns autoplay off.
Three 1280 by 800 textures, one full-screen triangle and no per-frame work while idle. Pixel ratio is capped at 1.5. Without WebGL it falls back to a 2D canvas crossfade.
| Section | Gallery |
|---|---|
| Techniques | WebGL, Carousel, Reveal and intro |
| Stack | Vanilla JS, WebGL, Canvas |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Creative WebGL Image Transitions by Yuri Artiukh 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
How do I add my own slides?
Replace the paint function with code that draws your images to 1280 by 800 canvases, and extend the slide count and caption list. The shader does not care how many slides exist.
Can I add a fourth transition style?
Yes. Add another branch to the fragment shader keyed on the style uniform and a matching entry in the style map.
Why does it pause on hover?
Autoplay should not change a slide while someone is reading or aiming at a button, so it waits until the pointer leaves.