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.
| Section | Hero |
|---|---|
| Techniques | Pointer interaction, WebGL |
| Stack | Vanilla JS, WebGL |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Liquid 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.
More elements
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.