Copied!
Gallery

WebGL image transition

Three generated slides that change with a WebGL shader transition: dissolve, wipe-warp or pixel.

← Library

Element · Vanilla JS + WebGL + CanvasUpdated November 5, 2026

3DElementVanilla JS + WebGL + Canvas

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.

SectionGallery
TechniquesWebGL, Carousel, Reveal and intro
StackVanilla JS, WebGL, Canvas
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byCreative 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.

3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay. Shader gradient backgroundA slow, flowing noise gradient drawn with one WebGL fragment shader, with a CSS fallback. Circle reveal introA gate button opens a circle from the click point that grows to reveal the page.

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.