---
title: "Ordered dithering and a CRT mask, on a canvas"
description: "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."
date: "2026-05-30"
tags: ["canvas", "dither", "retro", "frontend"]
---

I kept reaching for this little dither pass across projects, so I pulled it out
of [HypeDuel](https://hypeduel.com). Drop in an image (or upload one), pick
mono or color, and flip on the CRT 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:

```ts
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:

```ts
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:

```ts
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](https://hypeduel.com), a project I work on at B3. Grab
the component on the [Dither](/components/dither) page.
