← Back
← Back to Writing

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:

NOISE PROTOCOL v0

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 + seed always produces the same value. No Math.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.