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 "Pixel distortion hover" for my site using the Enrichr Library element at https://enrichr.dev/elements/pixel-distortion-hover.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/pixel-distortion-hover.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:
- accent: #e6ff55
- block: 16
- radius: 90
- strength: 1.2
- relax: 0.08
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/pixel-distortion-hover.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-pdh">
<div class="ef-pdh__wrap">
<canvas class="ef-pdh__cv" tabindex="0" role="img" aria-label="Gradient artwork that pixelates and ripples around the pointer. Use arrow keys to move the effect."></canvas>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-pdh { width: 100%; }
.ef-pdh__wrap { box-sizing: border-box; width: 100%; height: 18rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; }
.ef-pdh__cv { display: block; width: 100%; height: 100%; cursor: crosshair; outline: none; touch-action: none; }
.ef-pdh__cv:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }JavaScript
// Enrichr Library · https://enrichr.dev
// Pixel distortion hover: gradient artwork on a 2D canvas. Cells near the pointer turn into flat colour blocks sampled from a displaced
// position, so the picture pixelates and smears along the pointer's motion, then each cell relaxes back. Canvas 2D only (no WebGL).
// Pauses off-screen; reduced motion keeps the pixelation under the pointer but drops the displacement and the relax animation.
// Arrow keys nudge the effect when the canvas is focused.
// mount(root, { background, accent, block (px), radius (px), strength (0-3), relax (0.02-0.3) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-pdh__wrap");
var cv = root.querySelector(".ef-pdh__cv");
var ctx = cv.getContext("2d");
var src = document.createElement("canvas"), sctx = src.getContext("2d");
var opts = {}, W = 0, H = 0, dpr = 1, cols = 0, rows = 0, cell = 16;
var inten = new Float32Array(0), vx = new Float32Array(0), vy = new Float32Array(0);
var raf = 0, visible = true, live = false, offs = [];
var px = -1, py = -1, kx = 0, ky = 0;
// Enrichr puzzle-piece artwork (viewBox + path), painted with Path2D so the demo needs no external file.
var PIECES = [["1513.7 1453.9 322.3 322.3","M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"],["1423.36 860.8 503.07 322.3","M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"],["1513.7 166.86 322.3 503.06","M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"]];
function drawPiece(g, i, cx, cy, size, rot, color, alpha) {
var p = PIECES[i], vb = p[0].split(" ").map(Number), k = size / Math.max(vb[2], vb[3]);
g.save(); g.globalAlpha = alpha; g.fillStyle = color;
g.translate(cx, cy); g.rotate(rot * Math.PI / 180); g.scale(k, k); g.translate(-(vb[0] + vb[2] / 2), -(vb[1] + vb[3] / 2));
g.fill(new Path2D(p[1]));
g.restore();
}
function drawArt() {
var a = opts.accent, bg = opts.background, w = src.width, h = src.height, g;
g = sctx.createLinearGradient(0, 0, w, h); g.addColorStop(0, "#005236"); g.addColorStop(0.55, bg); g.addColorStop(1, "#0a6b46");
sctx.fillStyle = g; sctx.fillRect(0, 0, w, h);
g = sctx.createRadialGradient(w * 0.72, h * 0.35, 0, w * 0.72, h * 0.35, h * 0.55); g.addColorStop(0, a); g.addColorStop(0.95, a); g.addColorStop(1, "rgba(0,0,0,0)");
sctx.fillStyle = g; sctx.fillRect(0, 0, w, h);
sctx.fillStyle = "#fbf8f1"; sctx.beginPath(); sctx.arc(w * 0.28, h * 0.62, h * 0.2, 0, 6.2832); sctx.fill();
drawPiece(sctx, 1, w * 0.66, h * 0.86, w * 0.78, -14, "#c6f26b", 0.92);
drawPiece(sctx, 2, w * 0.1, h * 0.14, h * 1.0, 18, "#2f7d5b", 0.95);
drawPiece(sctx, 0, w * 0.5, h * 0.2, h * 0.5, -24, "#0a6b46", 0.9);
sctx.strokeStyle = a; sctx.lineWidth = Math.max(2, h * 0.012); sctx.globalAlpha = 0.3;
for (var i = 0; i < 7; i++) { sctx.beginPath(); sctx.moveTo(0, h * (0.1 + i * 0.13)); sctx.lineTo(w, h * (0.02 + i * 0.13)); sctx.stroke(); }
sctx.globalAlpha = 1;
}
function render() {
ctx.imageSmoothingEnabled = false;
ctx.globalAlpha = 1;
ctx.drawImage(src, 0, 0);
var any = false, D = cell * dpr, mx = src.width - 1, my = src.height - 1;
for (var r = 0; r < rows; r++) for (var c = 0; c < cols; c++) {
var i = r * cols + c, k = inten[i];
if (k < 0.02) continue;
any = true;
var sx = (c * cell + cell / 2 - vx[i] * k * opts.strength * cell * 2) * dpr;
var sy = (r * cell + cell / 2 - vy[i] * k * opts.strength * cell * 2) * dpr;
sx = Math.max(0, Math.min(mx, sx)); sy = Math.max(0, Math.min(my, sy));
ctx.globalAlpha = Math.min(1, k * 1.6);
ctx.drawImage(src, sx, sy, 1, 1, c * D, r * D, D, D);
if (!reduce) inten[i] = k * (1 - opts.relax);
}
ctx.globalAlpha = 1;
return any;
}
function frame() {
raf = 0;
live = render();
if (live && visible && !reduce) raf = requestAnimationFrame(frame);
}
function kick() { live = true; if (reduce) { render(); return; } if (!raf && visible) raf = requestAnimationFrame(frame); }
function stamp(x, y, dx, dy) {
var rad = Math.max(1, opts.radius / cell), cc = x / cell, cr = y / cell;
var c0 = Math.max(0, Math.floor(cc - rad)), c1 = Math.min(cols - 1, Math.ceil(cc + rad));
var r0 = Math.max(0, Math.floor(cr - rad)), r1 = Math.min(rows - 1, Math.ceil(cr + rad));
var mag = Math.sqrt(dx * dx + dy * dy) || 1;
for (var r = r0; r <= r1; r++) for (var c = c0; c <= c1; c++) {
var d = Math.sqrt((c + 0.5 - cc) * (c + 0.5 - cc) + (r + 0.5 - cr) * (r + 0.5 - cr)) / rad;
if (d > 1) continue;
var i = r * cols + c, k = 1 - d;
if (k > inten[i]) inten[i] = k;
vx[i] = reduce ? 0 : dx / mag; vy[i] = reduce ? 0 : dy / mag;
}
kick();
}
function mv(e) {
var r = cv.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
var dx = px < 0 ? 0 : x - px, dy = py < 0 ? 0 : y - py;
px = x; py = y;
if (reduce) inten.fill(0);
stamp(px, py, dx, dy);
}
function lv() { px = py = -1; if (reduce) { inten.fill(0); render(); } }
function kd(e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
if (!kx && !ky) { kx = W / 2; ky = H / 2; }
kx = Math.max(0, Math.min(W, kx + d[0] * cell * 2)); ky = Math.max(0, Math.min(H, ky + d[1] * cell * 2));
if (reduce) inten.fill(0);
stamp(kx, ky, d[0], d[1]);
}
function size() {
var r = wrap.getBoundingClientRect();
W = Math.max(40, Math.round(r.width)); H = Math.max(40, Math.round(r.height));
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = src.width = Math.round(W * dpr); cv.height = src.height = Math.round(H * dpr);
cell = Math.max(4, opts.block);
cols = Math.ceil(W / cell); rows = Math.ceil(H / cell);
inten = new Float32Array(cols * rows); vx = new Float32Array(cols * rows); vy = new Float32Array(cols * rows);
drawArt();
render();
}
function clear() { offs.forEach(function (f) { f(); }); offs = []; }
function bind() {
cv.addEventListener("pointermove", mv); cv.addEventListener("pointerleave", lv); cv.addEventListener("keydown", kd);
offs.push(function () { cv.removeEventListener("pointermove", mv); cv.removeEventListener("pointerleave", lv); cv.removeEventListener("keydown", kd); });
if (window.ResizeObserver) { var ro = new ResizeObserver(size); ro.observe(wrap); offs.push(function () { ro.disconnect(); }); }
if (window.IntersectionObserver) {
var io = new IntersectionObserver(function (en) {
visible = en[0].isIntersecting;
if (visible && live) kick(); else if (!visible && raf) { cancelAnimationFrame(raf); raf = 0; }
});
io.observe(wrap); offs.push(function () { io.disconnect(); });
}
}
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", block: 16, radius: 90, strength: 1.2, relax: 0.08 }, opts, next || {});
wrap.style.background = opts.background;
cv.style.setProperty("--ef-acc", opts.accent);
if (prev.block !== opts.block || prev.accent !== opts.accent || prev.background !== opts.background) size();
}
update(o);
bind();
return { update: update, destroy: function () { clear(); if (raf) cancelAnimationFrame(raf); raf = 0; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","block":16,"radius":90,"strength":1.2,"relax":0.08});How it works
The artwork is painted once onto an offscreen canvas and copied to the visible canvas. The visible canvas is split into a grid of cells, each holding an intensity value. When the pointer moves, cells within the radius get an intensity that falls off with distance, plus the pointer's direction. Each frame, every active cell draws a single sampled pixel from the offscreen artwork, offset against the motion and scaled up with smoothing off, which makes a flat colour block. Intensity then decays by a fixed fraction per frame, so the blocks fade out and the clean image returns. The loop stops when no cell is active and when the element leaves the viewport.
Accessibility and performance
The canvas has an image role and a text label, and is focusable. Arrow keys move the effect so keyboard users can trigger it. Under reduced motion the cells still pixelate under the pointer but there is no displacement and no animated relax, and the blocks clear as soon as the pointer moves on. The effect is decorative, so do not put essential text on the canvas.
Only cells with intensity above a small threshold are drawn, and each is one drawImage call from a 1 pixel source. The loop sleeps when nothing is active and pauses off-screen via IntersectionObserver. Device pixel ratio is capped at 2. Very small block sizes over a very large canvas will increase the cell count, so raise the block size for big areas.
| Section | Gallery |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Pixel Distortion Effect with Three.js 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
Why not use WebGL like the original technique?
A displacement shader is smoother, but the 2D canvas version needs no GPU context, works anywhere, and is enough for a blocky mosaic look.
Can I use my own image?
Yes. Replace the drawArt function with a drawImage of your picture onto the offscreen canvas. Use a same-origin or CORS-enabled image.
How do I make the blocks stay longer?
Lower the relax speed. Higher values make the blocks fade faster.