How I built my animated background

The background on my homepage is a 2D canvas, not a video or a CSS gradient. Each frame samples a smooth noise field on a grid and decides where to place tiny squares. Here is the effect, peeled back one layer at a time.

01

Sample a field of noise

I start with 3D simplex noise. For now the third coordinate stays at zero. I sample every 12 CSS pixels and map the result from roughly −1…1 to 0…1. This grayscale view shows the raw values: neighboring samples change gradually, so the result looks like clouds instead of random static.

const noise3D = createNoise3D();
for (let y = 0; y <= height; y += 12) {
  for (let x = 0; x <= width; x += 12) {
    const value = (noise3D(x * 0.002, y * 0.002, 0) + 1) / 2;
  }
}
Live preview · Sample a field of noise

02

Move through the field

The noise function has a third dimension. Feeding elapsed animation time into that coordinate gives us a new, nearby slice on every frame. The shapes evolve smoothly without moving the whole canvas. A speed of 0.00004 keeps the motion subtle (requestAnimationFrame supplies time in milliseconds).

const t = time * 0.00004;
const value = (noise3D(x * 0.002, y * 0.002, t) + 1) / 2;
requestAnimationFrame(draw);
Live preview · Move through the field

03

Keep the highlights and add color

Most of the field should disappear into the dark slate background. I discard values at or below 0.6, then remap the remaining 0.6…1 range to 0…1. In this intermediate preview, blue squares use that remapped value as their opacity, so you can see the brightness before the final on/off decision.

if (value <= 0.6) continue;
const remapped = (value - 0.6) / (1 - 0.6);
// Preview only: rgba(76, 169, 239, 0.6 * remapped)
Live preview · Keep the highlights and add color

04

Dither into dots

On the actual homepage, each square is either drawn or skipped. A repeating 4×4 Bayer matrix gives each grid position a different threshold. Comparing the remapped brightness with that threshold turns smooth highlights into a patterned cluster of evenly colored blue dots. The matrix values are offset by 0.5 and divided by 16 to put them between zero and one.

const spacing = 12;
if (value <= 0.6) continue;
const remapped = (value - 0.6) / (1 - 0.6);
const bayer = [
  [0, 8, 2, 10], [12, 4, 14, 6],
  [3, 11, 1, 9], [15, 7, 13, 5],
];
const threshold = (bayer[row % 4][col % 4] + 0.5) / 16;
if (remapped > threshold) {
  ctx.fillRect(x - 2.5, y - 2.5, 5, 5);
}
Live preview · Dither into dots

Putting it all together

Switch between the four layers to see how the same noise field becomes the finished background: first the raw samples, then motion, colored highlights, and finally dithered dots.

Live preview · Noise

Performance considerations

The noise is sampled in JavaScript and the canvas is cleared and redrawn each frame. The number of noise samples matters more than the number of dots that survive the cutoff.

  • Keep the sampling grid coarse

    With 12px spacing, a 1920 × 1080 canvas needs about 14,600 noise samples per frame. Halving the spacing roughly quadruples that work, so the grid size is both a visual choice and a performance knob.

  • Skip unnecessary drawing

    Values below the brightness cutoff never reach fillRect. Above it, Bayer dithering only draws squares that pass the threshold. The noise is still sampled at every grid point, but fewer canvas rectangles are painted.

  • Respect the screen and the user

    requestAnimationFrame schedules redraws with the browser. The canvas backing resolution follows devicePixelRatio but is capped at 2 to limit pixel memory. Resizes are debounced by 120ms, and a reduced-motion preference replaces the animation with a single still frame.

  • Only animate what is visible

    Unlike the homepage's single full-screen canvas, this guide has several previews. Each one uses an IntersectionObserver to stop requesting frames while off-screen and starts again when it comes back into view.

Putting it on the page

The finished canvas sits behind the homepage content, filling its container. I clear and redraw it on each animation frame, scale its backing pixels for high-DPI displays, and resize it when its container changes. When reduced motion is requested, I draw one still frame instead. The previews above also pause when they leave the viewport.

See it on the homepage →