The Noise Protocol
The Noise Protocol
Pure #000000 is deeply unnatural. Stare at a solid black <div> and it reads as a hole in the page — an absence, not a surface. Real black surfaces have texture: the grain of paper, the weave of fabric, the subtle mottling of matte-finished metal.
Adding procedural noise to dark backgrounds is a small detail that makes a disproportionate difference. Here’s the technique I use on this site, running live:
Each cell in the grid is a <rect> with its opacity determined by a hash function. The version counter ticking in the corner shows successive noise frames — each one unique, but all sharing the same visual density.
The hash function
The core of the technique is a one-liner that turns an index and a seed into a pseudorandom value between 0 and 1:
function noise(i: number, seed: number): number {
const x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453;
return x - Math.floor(x);
} This is a classic fragment-shader trick ported to JavaScript. The magic numbers (127.1, 311.7, 43758.5453) are chosen to produce values that look random when you take the fractional part. It’s not cryptographically random — it’s visually random, which is all we need.
The key properties that make this work:
- Deterministic. Same
i+seedalways produces the same value. NoMath.random()means the noise is reproducible and testable. - Fast. One
sin, one multiply, one floor. No memory allocation, no lookup tables. - Seedable. Changing the seed produces a completely different noise field, which enables animation by incrementing the seed each frame.
SVG over Canvas
For noise overlays, you might expect <canvas> to be the obvious choice — and for per-pixel noise at high resolution, it is. But for the grid-cell approach (24×16 cells on this site), SVG has advantages:
<svg viewBox="0 0 100 {100 * ROWS / COLS}">
{#each Array(ROWS) as _, r}
{#each Array(COLS) as _, c}
{@const idx = r * COLS + c}
<rect
x={c * CELL} y={r * CELL}
width={CELL} height={CELL}
fill="var(--color-text)"
opacity={noise(idx, seed) * 0.25}
/>
{/each}
{/each}
</svg> Because there are only 384 elements (24 × 16), SVG handles this comfortably. The elements inherit CSS custom properties for theming, they scale perfectly at any viewport size via viewBox, and Svelte’s {#each} block handles the reactive updates when the seed changes.
The opacity ceiling
The most important parameter is the maximum opacity — here, 0.25 (25%). This is the difference between “subtle texture” and “visible static.” At 25%, the noise is felt more than seen. Your eye registers that the surface isn’t perfectly flat, but you can’t point to individual cells.
Push it to 40% and the grid becomes distracting. Drop it to 10% and you might as well not bother — it’s invisible on most displays. The sweet spot depends on the background color and the cell size, but for dark backgrounds with cells around 4px, 20-30% is the range.
Animating the noise
Static noise is good. Slowly shifting noise is better — it adds a sense of life without demanding attention. The animation is just a setInterval bumping the seed:
$effect(() => {
const id = setInterval(() => { seed++ }, 150);
return () => clearInterval(id);
}); At 150ms intervals (roughly 6-7fps), the noise shifts at a pace that reads as organic drift rather than flickering. Full 60fps noise animation would be aggressive and distracting — the low frame rate is deliberate.
When to use this
This technique works anywhere you want a dark surface to feel physical:
- Page backgrounds — the global background of this site uses a similar approach
- Card surfaces — code blocks and panel backgrounds benefit from subtle grain
- Hero sections — large dark areas that would otherwise feel empty
- Loading states — animated noise communicates activity without spinners
It’s a trick borrowed from film grain and print texture. Digital surfaces don’t need to be sterile. A little noise goes a long way.