Skip to content
← writing

Ordered dithering and a CRT mask, on a canvas

canvas
dither
retro
frontend

I kept reaching for this little dither pass across projects, so I pulled it out of HypeDuel. Drop in an image (or upload one), pick mono or color, and flip on the CRT mask:

image
mode
levels
4
CRT

Bayer 4×4 ordered dither · optional animated CRT subpixel mask

Ordered dithering in one matrix

Dithering fakes shades you don't have by scattering pixels in a pattern instead of banding. Ordered (Bayer) dithering uses a fixed matrix to give each pixel a small, position-based threshold. A 4×4 matrix, normalized to offsets between -0.5 and 0.5:

const BAYER = [0,8,2,10, 12,4,14,6, 3,11,1,9, 15,7,13,5];
const T = BAYER.map((v) => (v + 0.5) / 16 - 0.5);

Then for every pixel you add its matrix offset before quantizing. The rounding errors line up into that even crosshatch instead of flat steps:

const t = T[(y & 3) * 4 + (x & 3)];
const q = Math.round((value / 255 + t) * (levels - 1));
out = (q / (levels - 1)) * 255;

Mono mode thresholds luminance to a single bit (black or white). Color mode runs that quantize per channel to levels steps, so levels={2} is harsh and levels={6} is nearly smooth. It's x & 3 / y & 3 indexing, so it costs basically nothing per pixel.

The CRT mask is a separate, animated pass

The retro shimmer is its own thing. For each pixel, pick one "active" RGB subpixel by column, dim the other two, stagger the choice per scanline, and advance it every frame:

const subPixel = (x + (y & 1) + frame) % 3; // 0:R 1:G 2:B
// dim the two inactive channels by (1 - strength), plus a little jitter

A touch of coordinate-seeded noise keeps it from looking like a clean stripe. Because the active subpixel cycles each frame, the whole thing faintly shimmers, like phosphor. It runs on a copy of the dithered base so you're not re-dithering every frame, just re-masking.

Worth knowing

It reads the canvas back with getImageData, so the source image has to be same-origin or CORS-enabled, and it's a per-pixel JS loop, so it's best on modest sizes (avatars, thumbnails, hero art), not a 4K background. For a static look, skip the CRT pass entirely and it's a single cheap dither.

Pulled out of HypeDuel, a project I work on at B3. Grab the component on the Dither page.

Ask your agent to implement this

Read the full writeup at https://seangeng.com/writing/ordered-dithering-and-a-crt-mask.md and implement it in my project.

It covers: Ordered dithering and a CRT mask, on a canvas — A Bayer 4×4 ordered dither that fakes more shades than you have, plus an animated CRT subpixel mask for the shimmering-phosphor look. Both are small canvas passes, no shaders. Pulled out of ai-arena.

Requirements:
- Follow the technique/approach exactly as described in the writeup.
- Adapt names, colors, and styling to my project's existing conventions.
- If it's a component, make it reusable with sensible props and TypeScript types.
- Keep it accessible: semantic HTML, keyboard support, and respect prefers-reduced-motion.
- When done, tell me which files you created or changed and how to use it.

Paste into Claude Code, Codex, Cursor, or any agent. view raw .md download source .zip