Copied!
Hero

Liquid logo distortion

Your logo turns to liquid under the pointer: it warps, splits into red, green and blue, and ripples, then settles back sharp.

← Library

Element · Vanilla JS + WebGLUpdated November 6, 2026

HeroElementVanilla JS + WebGL

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 "Liquid logo distortion" for my site using the Enrichr Library element at https://enrichr.dev/elements/liquid-logo-distortion.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/liquid-logo-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:
- text: Enrichr
- layout: row
- warp: 0.15
- split: 0.04
- morph: 0.02
- fine: 15
- radius: 0.25
- idle: on
- background: #fbf8f1
- ink: #0c1e18
- mark: #0c1e18

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/liquid-logo-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-lqd"><canvas class="ef-lqd__gl" role="img" aria-label="The Enrichr logo, a puzzle-piece mark and the word Enrichr, which ripples like liquid with a colour split where the pointer moves"></canvas></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-lqd { position: relative; width: 100%; height: 100%; min-height: 26rem; overflow: hidden; border-radius: 1rem; background: #fbf8f1; touch-action: pan-y; }
.ef-lqd .ef-lqd__gl, .ef-lqd .ef-lqd__fb { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Liquid logo distortion: the Enrichr puzzle-piece mark and wordmark are drawn on a 2D canvas, uploaded as a WebGL texture,
// and one fragment shader warps that texture around the pointer. The warp pushes pixels away from the pointer by its
// speed, splits the red and blue channels apart (an RGB split), and adds a slow sine morph plus a fine noise ripple, all
// inside a soft circle that grows with speed. The pointer is smoothed with a 0.1 lerp per frame, its speed is the distance
// moved per event times 100 (capped at 2), eased in at 0.1 per frame and decayed by 0.8 every 50 ms, so a flick leaves a
// short liquid wake that settles. With idle on, a virtual pointer sweeps across the logo now and then while no one is
// hovering. Reduced motion draws the logo once, sharp, and ignores the pointer. Without WebGL the plain 2D logo shows.
// The loop pauses off screen and in hidden tabs.
// mount(root, { text (wordmark), layout (row | stack | text), warp, split (RGB split), morph (random morph),
//               fine (fine displacement frequency), radius, idle (on | off), background, ink (text colour), mark (mark colour) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-lqd");
  var canvas = root.querySelector(".ef-lqd__gl");
  var tex2d = document.createElement("canvas");
  var tctx = tex2d.getContext("2d");
  var MARK = "m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z";
  var MB = { x: 4.068, y: 2.566, w: 37.866, h: 45.587 }; // the mark's bounding box inside its 50 x 50 view box
  var markPath = typeof Path2D === "function" ? new Path2D(MARK) : null;
  var FONT = "'Parkinsans', 'Space Grotesk', sans-serif";
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, time = 0, acc = 0;
  var W = 1, H = 1, dpr = 1, gl = null, prog = null, buf = null, tex = null, u = {}, fb = null;
  // pointer state, in 0..1 with y up, exactly like the original: target, smoothed, last, speed target, smoothed speed
  var tx = -10, ty = -10, mx = -10, my = -10, lx = 0, ly = 0, tv = 0, vel = 0;
  var inside = false, lastReal = -1e9, sweep = null, sweepDir = 1, nextSweep = 1.2;

  var VS = "attribute vec2 p;varying vec2 vUv;void main(){vUv=p*.5+.5;gl_Position=vec4(p,0.,1.);}";
  var FS = [
    "#ifdef GL_FRAGMENT_PRECISION_HIGH", "precision highp float;", "#else", "precision mediump float;", "#endif",
    "uniform sampler2D tDiffuse;uniform vec2 uResolution;uniform vec2 uMouse;uniform float uVelo;uniform float uTime;",
    "uniform float uWarpStrength;uniform float uCaStrength;uniform float uRadius;uniform float uNoiseStrength;uniform float uFineNoise;",
    "varying vec2 vUv;",
    "float random(vec2 st){return fract(sin(dot(st.xy,vec2(12.9898,78.233)))*43758.5453123);}",
    "float noise(vec2 st){vec2 i=floor(st);vec2 f=fract(st);float a=random(i);float b=random(i+vec2(1.,0.));",
    "float c=random(i+vec2(0.,1.));float d=random(i+vec2(1.,1.));vec2 u=f*f*(3.-2.*f);",
    "return mix(a,b,u.x)+(c-a)*u.y*(1.-u.x)+(d-b)*u.x*u.y;}",
    "void main(){vec2 uv=vUv;vec2 aspect=vec2(uResolution.x/uResolution.y,1.);vec2 mouse=uMouse*aspect;vec2 st=uv*aspect;",
    "vec2 dir=st-mouse;float dist=length(dir);vec2 normDir=dist>0.?dir/dist:vec2(0.);",
    "float radius=uRadius+(uVelo*.1);float strength=smoothstep(radius,0.,dist);float warpAmount=strength*uVelo*uWarpStrength;",
    "float t=uTime*3.;",
    "vec2 noiseDisp=vec2(sin(uv.y*12.+t)*cos(uv.x*12.-t),cos(uv.y*12.+t)*sin(uv.x*12.-t))*uNoiseStrength*strength*(uVelo+.5);",
    "float fineRand=(noise(uv*uFineNoise+vec2(t*.5,-t*.3))-.5)*2.;",
    "vec2 fineDisp=vec2(fineRand,-fineRand)*(uNoiseStrength*.75)*strength*(uVelo+.8);",
    "vec2 distortedUv=uv-normDir*warpAmount*aspect+noiseDisp+fineDisp;",
    "float caAmount=strength*uVelo*uCaStrength;vec2 rgbShift=normDir*caAmount*aspect;",
    "float r=texture2D(tDiffuse,distortedUv+rgbShift).r;float g=texture2D(tDiffuse,distortedUv).g;float b=texture2D(tDiffuse,distortedUv-rgbShift).b;",
    "gl_FragColor=vec4(r,g,b,1.);}"
  ].join("\n");

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }

  // ---- the logo texture, drawn by hand on a 2D canvas (this replaces html2canvas) ----
  function fontAt(px) { return "700 " + px + "px " + FONT; }
  function lines(text, fs) {
    tctx.font = fontAt(fs);
    var one = [text], words = text.split(/\s+/);
    if (words.length < 2) return one;
    var best = null, bestW = 1e9;
    for (var i = 1; i < words.length; i++) {
      var a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
      var w = Math.max(tctx.measureText(a).width, tctx.measureText(b).width);
      if (w < bestW) { bestW = w; best = [a, b]; }
    }
    return best || one;
  }
  function drawTexture() {
    var pw = Math.max(2, Math.round(W * dpr)), ph = Math.max(2, Math.round(H * dpr));
    if (tex2d.width !== pw || tex2d.height !== ph) { tex2d.width = pw; tex2d.height = ph; }
    var c = tctx;
    c.setTransform(dpr, 0, 0, dpr, 0, 0);
    c.fillStyle = opts.background; c.fillRect(0, 0, W, H);
    var text = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim();
    var layout = opts.layout, showMark = layout !== "text" && markPath, showText = text.length > 0;
    if (!showMark && !showText) return;
    // measure at 100px, then scale the whole lockup to fit 80% of the width and about half the height
    var ls = [text], fs = 100, tw = 0, i;
    if (showText) {
      c.font = fontAt(100);
      tw = c.measureText(text).width;
      if (tw > 0 && layout !== "row" && text.indexOf(" ") > 0 && (W * 0.8) / tw * 100 < H * 0.16) { ls = lines(text, 100); }
      tw = 0; for (i = 0; i < ls.length; i++) tw = Math.max(tw, c.measureText(ls[i]).width);
    }
    var m = c.measureText("Hg"), asc = m.actualBoundingBoxAscent || 74, desc = m.actualBoundingBoxDescent || 22;
    var lineH = 100 * 0.95, textH = showText ? asc + desc + (ls.length - 1) * lineH : 0;
    var markH = layout === "stack" ? 130 : 112, markW = markH * MB.w / MB.h, gap = showMark && showText ? (layout === "stack" ? 26 : 30) : 0;
    if (!showMark) { markW = 0; markH = 0; }
    var boxW = layout === "stack" ? Math.max(tw, markW) : markW + gap + tw;
    var boxH = layout === "stack" ? markH + gap + textH : Math.max(markH, textH);
    var k = Math.min((W * 0.8) / Math.max(1, boxW), (H * 0.62) / Math.max(1, boxH));
    var ox = (W - boxW * k) / 2, oy = (H - boxH * k) / 2;
    c.save();
    c.translate(ox, oy); c.scale(k, k);
    var mx0 = layout === "stack" ? (boxW - markW) / 2 : 0, my0 = layout === "stack" ? 0 : (boxH - markH) / 2;
    if (showMark) {
      c.save();
      c.translate(mx0, my0); c.scale(markH / MB.h, markH / MB.h); c.translate(-MB.x, -MB.y);
      c.fillStyle = opts.mark; c.fill(markPath);
      c.restore();
    }
    if (showText) {
      c.fillStyle = opts.ink; c.font = fontAt(100); c.textBaseline = "alphabetic";
      var tx0 = layout === "stack" ? 0 : markW + gap, ty0 = layout === "stack" ? markH + gap : (boxH - textH) / 2;
      for (i = 0; i < ls.length; i++) {
        var lw = c.measureText(ls[i]).width;
        var x = layout === "stack" ? (boxW - lw) / 2 : tx0;
        c.fillText(ls[i], x, ty0 + asc + i * lineH);
      }
    }
    c.restore();
  }

  // ---- WebGL ----
  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);
    buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    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);
    ["tDiffuse", "uResolution", "uMouse", "uVelo", "uTime", "uWarpStrength", "uCaStrength", "uRadius", "uNoiseStrength", "uFineNoise"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
    tex = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex);
    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);
    gl.uniform1i(u.tDiffuse, 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.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, tex2d);
  }
  function render() {
    if (!gl || !prog) return;
    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.uniform2f(u.uResolution, W, H);
    gl.uniform2f(u.uMouse, mx, my);
    gl.uniform1f(u.uVelo, vel);
    gl.uniform1f(u.uTime, time);
    gl.uniform1f(u.uWarpStrength, opts.warp);
    gl.uniform1f(u.uCaStrength, opts.split);
    gl.uniform1f(u.uNoiseStrength, opts.morph);
    gl.uniform1f(u.uFineNoise, opts.fine);
    gl.uniform1f(u.uRadius, opts.radius);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }
  function repaint() { drawTexture(); if (fb) { fb.width = tex2d.width; fb.height = tex2d.height; fb.getContext("2d").drawImage(tex2d, 0, 0); } else { upload(); render(); } }

  // ---- pointer: the original's mousemove maths, per pointer event, in the element's own box ----
  function moveTo(nx, ny) {
    tx = nx; ty = ny;
    var dx = tx - lx, dy = ty - ly;
    tv = Math.min(Math.sqrt(dx * dx + dy * dy) * 100, 2.0);
    lx = tx; ly = ty;
  }
  function onMove(e) {
    var r = wrap.getBoundingClientRect();
    if (r.width < 1 || r.height < 1) return;
    inside = true; lastReal = performance.now(); sweep = null;
    moveTo((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
  }
  function onEnter() { inside = true; lastReal = performance.now(); sweep = null; }
  function onLeave() { inside = false; lastReal = performance.now(); }

  // idle demo: a virtual pointer sweeps across the logo on a gentle arc, from just outside one edge to just outside the other
  function idleStep(now, dt) {
    if (opts.idle !== "on" || inside || now - lastReal < 1800) { nextSweep = Math.max(nextSweep, 0.6); return; }
    if (!sweep) {
      nextSweep -= dt;
      if (nextSweep > 0) return;
      var y0 = 0.35 + Math.random() * 0.3, y1 = 0.35 + Math.random() * 0.3;
      sweep = { t: 0, d: 1.5, x0: sweepDir > 0 ? -0.15 : 1.15, x1: sweepDir > 0 ? 1.15 : -0.15, y0: y0, y1: y1, bow: (Math.random() - 0.5) * 0.4 };
      sweepDir = -sweepDir;
      if (mx < -1) { mx = tx = lx = sweep.x0; my = ty = ly = sweep.y0; }
    }
    sweep.t += dt / sweep.d;
    var p = Math.min(1, sweep.t), e = (1 - Math.cos(Math.PI * p)) / 2;
    moveTo(sweep.x0 + (sweep.x1 - sweep.x0) * e, sweep.y0 + (sweep.y1 - sweep.y0) * e + Math.sin(Math.PI * e) * sweep.bow);
    if (p >= 1) { sweep = null; nextSweep = 2.2 + Math.random() * 1.6; }
  }

  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;
    if (!visible || document.hidden) return;
    time += dt;
    acc += dt * 1000;
    while (acc >= 50) { tv *= 0.8; acc -= 50; } // the original's setInterval(() => targetVelocity *= 0.8, 50)
    idleStep(now, dt);
    var k = 1 - Math.pow(0.9, dt * 60); // lerp 0.1 per 60 fps frame, the same feel on any refresh rate
    mx += (tx - mx) * k; my += (ty - my) * k;
    vel += (tv - vel) * k;
    render();
  }

  function size() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = Math.max(1, wrap.clientWidth); H = Math.max(1, wrap.clientHeight);
    var pw = Math.round(W * dpr), ph = Math.round(H * dpr);
    if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph; }
    repaint();
  }
  function update(partial) {
    opts = Object.assign({ text: "Enrichr", layout: "row", warp: 0.15, split: 0.04, morph: 0.02, fine: 15, radius: 0.25, idle: "on",
      background: "#fbf8f1", ink: "#0c1e18", mark: "#0c1e18" }, opts, partial || {});
    opts.warp = num(opts.warp, 0.15); opts.split = num(opts.split, 0.04); opts.morph = num(opts.morph, 0.02);
    opts.fine = num(opts.fine, 15); opts.radius = Math.max(0.01, num(opts.radius, 0.25));
    wrap.style.background = opts.background;
    var t = String(opts.text || "").trim();
    canvas.setAttribute("aria-label", (t ? t + ": " : "") + (opts.layout !== "text" ? "the Enrichr puzzle-piece logo, " : "") + "which ripples like liquid with a colour split where the pointer moves");
    if (destroyed) return;
    repaint();
  }

  gl = canvas.getContext("webgl", { antialias: true, alpha: false, premultipliedAlpha: false, powerPreference: "low-power" });
  var ok = !!gl && setup();
  if (!ok) {
    canvas.style.display = "none";
    fb = document.createElement("canvas"); fb.className = "ef-lqd__fb";
    fb.setAttribute("role", "img"); fb.setAttribute("aria-label", "The Enrichr logo");
    wrap.appendChild(fb);
  }
  update(o);
  size();
  // the wordmark font may still be loading; redraw the texture when it lands
  if (document.fonts && document.fonts.load) {
    document.fonts.load("700 64px Parkinsans").then(function () { if (!destroyed) repaint(); }, function () {});
    document.fonts.ready.then(function () { if (!destroyed) repaint(); });
  }
  if (ok && !reduce) {
    wrap.addEventListener("pointermove", onMove, { passive: true });
    wrap.addEventListener("pointerdown", onMove, { passive: true });
    wrap.addEventListener("pointerenter", onEnter);
    wrap.addEventListener("pointerleave", onLeave);
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    last = performance.now(); raf = requestAnimationFrame(loop);
  }
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(wrap); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerdown", onMove);
      wrap.removeEventListener("pointerenter", onEnter);
      wrap.removeEventListener("pointerleave", onLeave);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      if (fb && fb.parentNode) fb.parentNode.removeChild(fb);
      if (gl) {
        var ext = gl.getExtension("WEBGL_lose_context");
        if (tex) gl.deleteTexture(tex); if (buf) gl.deleteBuffer(buf); if (prog) gl.deleteProgram(prog);
        if (ext) ext.loseContext();
      }
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Enrichr","layout":"row","warp":0.15,"split":0.04,"morph":0.02,"fine":15,"radius":0.25,"idle":"on","background":"#fbf8f1","ink":"#0c1e18","mark":"#0c1e18"});

How it works

The puzzle-piece mark and the wordmark are drawn on a hidden 2D canvas the size of the element, and that canvas becomes a WebGL texture on one full-screen triangle. A fragment shader reads the texture three times, once for each colour channel. Around the pointer, inside a soft circle whose size grows with speed, it pushes the picture away from the pointer, shifts red one way and blue the other, and adds a slow sine wobble and a fine noise ripple that both move with time. Speed is the distance the pointer moved in one event times 100, capped at 2. It eases in at a tenth per frame and decays by 0.8 every 50 milliseconds, and the pointer itself is smoothed by a tenth per frame, so a quick flick leaves a liquid wake that settles in about a second. With the idle demo on, a virtual pointer sweeps across the logo every few seconds while nobody is hovering.

Accessibility and performance

The canvas has a text label that names the wordmark and the logo, and it updates when the text changes. With prefers-reduced-motion the logo is drawn once, sharp, and the pointer and idle sweeps are ignored. The effect is decorative and needs no keyboard input. The colour split appears only while the pointer moves and fades within a second, so nothing flashes on its own.

One shader pass with three texture reads per pixel, at a pixel ratio capped at 2. The logo texture is redrawn only on resize, a parameter change or when the font finishes loading. The loop pauses off screen and in hidden tabs, and destroy deletes the texture, buffer and program and releases the WebGL context. Without WebGL the plain 2D logo is shown instead.

SectionHero
TechniquesPointer interaction, WebGL
StackVanilla JS, WebGL
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byLiquid hover distortion study by Enrichr on CodePen. 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 I use my own logo?

Yes. The mark is an SVG path drawn with Path2D, so swap the path string for your own and keep the bounding box numbers in step. Set the text to your wordmark or a short headline, or choose Text only.

Does it need three.js or html2canvas?

No. The original pen used three.js for one plane and html2canvas to photograph a heading. Here the heading is drawn straight onto a 2D canvas, which is faster and has no layout surprises, and the plane is plain WebGL.

Why does the colour split show red, green and blue edges on dark text?

The shader samples red, green and blue at slightly different spots, so the edges of the letters separate into channels. On a light background the fringes look cyan, magenta and yellow; on a dark background they look red, green and blue.

Can I turn off the idle sweeps?

Yes. Set Idle demo sweeps to Off and the logo stays still until someone moves the pointer over it, exactly like the original pen.