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;
}
}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);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)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);
}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.
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 →