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 "Ordered dither noise field" for my site using the Enrichr Library element at https://enrichr.dev/elements/dither-noise.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/dither-noise.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
- cell: 4
- levels: 4
- speed: 0.8
- spot: 0.8
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/dither-noise.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-dn">
<div class="ef-dn__wrap">
<canvas class="ef-dn__cv" aria-hidden="true"></canvas>
<svg class="ef-dn__src" viewBox="1513.7 1453.9 322.3 322.3" aria-hidden="true" focusable="false"><path fill="currentColor" d="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"/></svg>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-dn { width: 100%; height: 100%; }
.ef-dn__wrap { position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 18rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; touch-action: pan-y; }
.ef-dn__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; image-rendering: pixelated; image-rendering: crisp-edges; }
.ef-dn__src { position: absolute; width: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none; }JavaScript
// Enrichr Library · https://enrichr.dev
// Dither noise: a slowly moving gradient field quantised to 2-4 colour levels with an 8x8 ordered (Bayer) dither, drawn per cell into a small
// canvas and scaled up with crisp pixels. A soft light spot follows the pointer (and wanders on its own). A puzzle piece is masked in as
// dithered cream. Reduced motion draws one still frame.
// mount(root, { background, accent, cell (2-10 px), levels (2-4), speed (0-2), spot (0-1) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-dn__wrap");
var cv = root.querySelector(".ef-dn__cv");
var src = root.querySelector(".ef-dn__src path");
var ctx = cv.getContext("2d");
var opts = {}, gw = 0, gh = 0, img = null, mask = null, pal = [];
var t = 3, last = 0, raf = 0, vis = true, dead = false, offs = [];
var sx = 0.5, sy = 0.5, tsx = 0.5, tsy = 0.5, tact = 0;
var BAYER = (function () {
var m = [[0]], q = [[0, 2], [3, 1]];
for (var s = 0; s < 3; s++) {
var n = m.length, r = [];
for (var y = 0; y < n * 2; y++) { r[y] = []; for (var x = 0; x < n * 2; x++) r[y][x] = 4 * m[y % n][x % n] + q[y < n ? 0 : 1][x < n ? 0 : 1]; }
m = r;
}
var f = new Float32Array(64);
for (var j = 0; j < 8; j++) for (var i = 0; i < 8; i++) f[j * 8 + i] = (m[j][i] + 0.5) / 64;
return f;
})();
var vb = src ? src.parentNode.getAttribute("viewBox").split(/\s+/).map(Number) : [0, 0, 1, 1];
var shape = src ? new Path2D(src.getAttribute("d")) : null;
function rgb(c) { var n = parseInt(String(c).replace("#", ""), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function buildPalette() {
var st = [rgb(opts.background), rgb("#005236"), rgb("#2f7d5b"), rgb(opts.accent)], n = opts.levels;
pal = [];
for (var i = 0; i < n; i++) {
var f = n > 1 ? (i / (n - 1)) * 3 : 0, a = Math.min(2, Math.floor(f)), m = f - a;
pal.push([0, 1, 2].map(function (c) { return Math.round(st[a][c] + (st[a + 1][c] - st[a][c]) * m); }));
}
}
function buildMask() {
mask = new Uint8Array(gw * gh);
if (!shape) return;
var c = document.createElement("canvas"); c.width = gw; c.height = gh;
var g = c.getContext("2d"), s = Math.min(gw, gh) * 0.32, k = s / vb[2];
g.translate(gw - s * 0.62 - gw * 0.05, gh - s * 0.62 - gh * 0.06);
g.rotate(0.21); g.scale(k, k); g.translate(-(vb[0] + vb[2] / 2), -(vb[1] + vb[3] / 2));
g.fillStyle = "#000"; g.fill(shape);
var d = g.getImageData(0, 0, gw, gh).data;
for (var i = 0; i < mask.length; i++) mask[i] = d[i * 4 + 3] > 110 ? 1 : 0;
}
function render() {
if (!img) return;
var d = img.data, n = opts.levels, asp = gw / gh, k = n - 1, cream = rgb("#fbf8f1"), moss = rgb("#2f7d5b");
var rad2 = 0.2 * 0.2;
for (var y = 0; y < gh; y++) {
var v = y / gh;
for (var x = 0; x < gw; x++) {
var u = (x / gw) * asp;
var a = Math.sin(u * 3 + t * 0.5 + Math.sin(v * 3.5 + t * 0.35) * 1.4);
var b = Math.sin(v * 4 - t * 0.42 + u * 1.6);
var f = 0.46 + 0.26 * a + 0.16 * b + (v - 0.5) * 0.12;
var dx = u - sx * asp, dy = v - sy;
f = f * 0.78 + Math.exp(-(dx * dx + dy * dy) / rad2) * opts.spot * 0.62;
f = f < 0 ? 0 : f > 1 ? 1 : f;
var th = BAYER[(y & 7) * 8 + (x & 7)], i = y * gw + x, c;
if (mask[i]) c = f * 0.85 + 0.2 + (th - 0.5) * 0.8 > 0.62 ? cream : moss;
else { var q = Math.floor(f * k + th); c = pal[q < 0 ? 0 : q > k ? k : q]; }
d[i * 4] = c[0]; d[i * 4 + 1] = c[1]; d[i * 4 + 2] = c[2]; d[i * 4 + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function size() {
var r = wrap.getBoundingClientRect();
if (!r.width || !r.height) return;
gw = Math.max(8, Math.ceil(r.width / opts.cell)); gh = Math.max(8, Math.ceil(r.height / opts.cell));
cv.width = gw; cv.height = gh;
img = ctx.createImageData(gw, gh);
buildMask(); render();
}
function frame(ts) {
raf = 0;
if (!vis || dead) return;
var dt = Math.min(0.05, (ts - last) / 1000 || 0.016); last = ts;
t += dt * opts.speed;
if (tact < 0.5) { tsx = 0.5 + 0.32 * Math.sin(t * 0.55); tsy = 0.5 + 0.28 * Math.sin(t * 0.37 + 1); }
sx += (tsx - sx) * Math.min(1, dt * 5); sy += (tsy - sy) * Math.min(1, dt * 5);
render();
raf = requestAnimationFrame(frame);
}
function start() { if (reduce || raf || !vis || dead) return; last = performance.now(); raf = requestAnimationFrame(frame); }
function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
function mv(e) {
var r = wrap.getBoundingClientRect();
tsx = (e.clientX - r.left) / r.width; tsy = (e.clientY - r.top) / r.height; tact = 1;
if (reduce) { sx = tsx; sy = tsy; render(); }
}
function lv() { tact = 0; }
wrap.addEventListener("pointermove", mv);
wrap.addEventListener("pointerleave", lv);
offs.push(function () { wrap.removeEventListener("pointermove", mv); wrap.removeEventListener("pointerleave", lv); });
var ro = null, io = null;
if (window.ResizeObserver) { ro = new ResizeObserver(size); ro.observe(wrap); } else { window.addEventListener("resize", size); offs.push(function () { window.removeEventListener("resize", size); }); }
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (en) { vis = en[en.length - 1].isIntersecting; if (vis) start(); else stop(); });
io.observe(wrap);
}
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", cell: 4, levels: 4, speed: 0.8, spot: 0.8 }, opts, next || {});
opts.cell = Math.max(2, Math.min(10, Math.round(opts.cell)));
opts.levels = Math.max(2, Math.min(4, Math.round(opts.levels)));
wrap.style.background = opts.background;
buildPalette();
if (prev.cell !== opts.cell || !img) size(); else render();
start();
}
update(o);
return {
update: update,
destroy: function () {
dead = true; stop();
if (ro) ro.disconnect();
if (io) io.disconnect();
offs.forEach(function (f) { f(); }); offs = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","cell":4,"levels":4,"speed":0.8,"spot":0.8});How it works
The canvas is only one pixel per cell, so a 4px cell on a 520px box is a 130 by 130 canvas that CSS scales up with pixelated rendering. For each cell the script computes a smooth value from a couple of overlapping sines that drift with time, adds a Gaussian light spot at the pointer, then adds the matching value from an 8 by 8 Bayer threshold matrix before rounding to one of the levels. That rounding is what makes the ordered dither pattern. Levels map to a short ramp from the background through two greens to the accent. The puzzle piece is drawn once into an off-screen mask at the same low resolution and any masked cell is dithered between cream and moss instead. When the pointer is away the spot wanders on its own.
Accessibility and performance
Decorative only: the canvas is hidden from assistive tech and the page text should sit in normal elements above it. The dither is high contrast, so keep text on a solid panel rather than directly over it. With prefers-reduced-motion a single still frame is drawn and the spot only moves with the pointer.
Work is per cell, not per screen pixel, so a 4px cell on a large hero is tens of thousands of cells per frame, all in plain loops over one ImageData buffer. Raise the pixel size to cut cost sharply. The loop pauses when off screen and stops on destroy().
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Dither (React Bits) by React Bits on reactbits.dev. 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 does it look blocky?
That is the point of the effect. Each cell is a real pixel of the low resolution canvas. Lower the pixel size for a finer pattern.
What do the colour levels do?
Two levels gives a hard two-colour dither (background and accent). Four levels adds the two greens in between for smoother ramps.
Can I swap the puzzle piece for my own shape?
Yes. The shape is the path inside the hidden SVG in the markup. Replace its d attribute and viewBox.