Back to blog

Dithered gradients in a few lines of canvas

How the background of this site works — a tiny ImageData buffer, a Bayer matrix, and one CSS property.

  • canvas
  • graphics
  • retro

The background of this site is not a CSS gradient. It is a gradient rasterised into a 180 pixel wide buffer, dithered down to two colours, and then blown up across the viewport. That last step is what makes it look like a 1994 demo instead of a modern hero section.

Ordered dithering, briefly

Ordered dithering compares each pixel’s value against a threshold that varies across a small repeating tile. The classic tile is the Bayer matrix:

const BAYER = [
  [0, 32, 8, 40],
  [48, 16, 56, 24],
  [12, 44, 4, 36],
  [60, 28, 52, 20],
].map((row) => row.map((v) => (v + 0.5) / 64));

const on = value > BAYER[y & 3][x & 3];

With two colours and an 8×8 tile you get 65 perceived levels — plenty for a smooth-looking gradient, and all of it in integer comparisons.

Why the buffer is tiny

Per-pixel JavaScript is only slow when there are a lot of pixels. At 180×101 there are about 18,000 of them, which is nothing. The trick is to never draw at full resolution:

canvas {
  width: 100vw;
  height: 100svh;
  image-rendering: pixelated;
}

The browser scales the buffer up with nearest-neighbour sampling, so the dither pattern stays crisp and chunky instead of being blurred away.

Reacting to the cursor

The mouse moves the centre of a soft radial term in the gradient function. But if you follow the pointer directly, the background feels twitchy and pulls attention away from the text. The fix is a heavy lag:

currentX += (targetX - currentX) * 0.02;

At 30 frames per second that takes a couple of seconds to catch up, which reads as the background noticed you rather than the background is following you.


The whole thing is about 150 lines, runs at 30fps, and pauses itself when the tab is hidden or the visitor prefers reduced motion.