Person expertise depends on small, considerate particulars that match properly into the general design with out overpowering the person. This steadiness may be tough, particularly with applied sciences like WebGL. Whereas they will create superb visuals, they will additionally grow to be too sophisticated and distracting if not dealt with rigorously.
One refined however efficient method is the Bayer Dithering Sample. For instance, JetBrains’ latest Junie marketing campaign web page makes use of this strategy to craft an immersive and interesting ambiance that is still visually balanced and accessible.
On this tutorial, I’ll introduce you to the Bayer Dithering Sample. I’ll clarify what it’s, the way it works, and how one can apply it to your personal internet tasks to reinforce visible depth with out overpowering the person expertise.
Bayer Dithering
The Bayer sample is a sort of ordered dithering, which helps you to simulate gradients and depth utilizing a set matrix.

If we scale this matrix appropriately, we are able to goal particular values and create primary patterns.

Right here’s a easy instance:
// 2×2 Bayer matrix sample: returns a worth in [0, 1)
float Bayer2(vec2 a)
{
a = floor(a); // Use integer cell coordinates
return fract(a.x / 2.0 + a.y * a.y * 0.75);
// Equivalent lookup table:
// (0,0) → 0.0, (1,0) → 0.5
// (0,1) → 0.75, (1,1) → 0.25
}
Let’s walk through an example of how this can be used:
// 1. Base mask: left half is a black-to-white gradient
float mask = uv.y;
// 2. Right half: apply ordered dithering
if (uv.x > 0.5) {
float dither = Bayer2(fragCoord);
mask += dither - 0.5;
mask = step(0.5, mask); // binary threshold
}
// 3. Output the result
fragColor = vec4(vec3(mask), 1.0);
So with just a small matrix, we get four distinct dithering values—essentially for free.
See the Pen
Bayer2x2 by zavalit (@zavalit)
on CodePen.
Creating a Background Effect
This is still pretty basic—nothing too exciting UX-wise yet. Let’s take it further by creating a grid on our UV map. We’ll define the size of a “pixel” and the size of the matrix that determines whether each “pixel” is on or off using Bayer ordering.
const float PIXEL_SIZE = 10.0; // Size of each pixel in the Bayer matrix
const float CELL_PIXEL_SIZE = 5.0 * PIXEL_SIZE; // 5x5 matrix
float aspectRatio = uResolution.x / uResolution.y;
vec2 pixelId = floor(fragCoord / PIXEL_SIZE);
vec2 cellId = floor(fragCoord / CELL_PIXEL_SIZE);
vec2 cellCoord = cellId * CELL_PIXEL_SIZE;
vec2 uv = cellCoord/uResolution * vec2(aspectRatio, 1.0);
vec3 baseColor = vec3(uv, 0.0);
You’ll see a rendered UV grid with blue dots for pixels and white (and subsequent blocks of the same size) for the Bayer matrix.
See the Pen
Pixel & Cell UV by zavalit (@zavalit)
on CodePen.
Recursive Bayer Matrices
Bayer’s genius was a recursively generated mask that keeps noise high-frequency and code low-complexity. So now let’s try it out, and apply also larger dithering matrix:
float Bayer2(vec2 a) { a = floor(a); return fract(a.x / 2. + a.y * a.y * .75); }
#define Bayer4(a) (Bayer2(0.5 * (a)) * 0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(0.5 * (a)) * 0.25 + Bayer2(a))
#define Bayer16(a) (Bayer8(0.5 * (a)) * 0.25 + Bayer2(a))
...
if(uv.x > .2) dither = Bayer2 (pixelId);
if(uv.x > .4) dither = Bayer4 (pixelId);
if(uv.x > .6) dither = Bayer8 (pixelId);
if(uv.x > .8) dither = Bayer16(pixelId);
...
This gives us a nice visual transition from a basic UV grid to Bayer matrices of increasing complexity (2×2, 4×4, 8×8, 16×16).
See the Pen
Bayer Ranges Animation by zavalit (@zavalit)
on CodePen.
As you see, the 8×8 and 16×16 patterns are quite similar—beyond 8×8, the perceptual gain becomes minimal. So we’ll stick with Bayer8 for the next step.
Now, we’ll apply Bayer8 to a UV map modulated by fbm noise to make the result feel more organic—just as we promised.
See the Pen
Bayer fbm noise by zavalit (@zavalit)
on CodePen.
Adding Interactivity
Here’s where things get exciting: real-time interactivity that background videos can’t replicate. Let’s run a ripple effect around clicked points using the dithering pattern. We’ll iterate over all active clicks and compute a wave:
for (int i = 0; i < MAX_CLICKS; ++i) {
// convert this click to square‑unit UV
vec2 pos = uClickPos[i];
if(pos.x < 0.0 && pos.y < 0.0) proceed; // skip empty clicks
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution) )) * vec2(aspectRatio, 1.0);
float t = max(uTime - uClickTimes[i], 0.0);
float r = distance(uv, cuv);
float waveR = pace * t;
float ring = exp(-pow((r - waveR) / thickness, 2.0));
float atten = exp(-dampT * t) * exp(-dampR * r);
feed = max(feed, ring * atten); // brightest wins
}
Attempt to click on on the CodePen bellow:
See the Pen
Untitled by zavalit (@zavalit)
on CodePen.
Ultimate Ideas
As a result of your entire Bayer-dither background is generated in a single GPU cross, it renders in below 0.2 ms even at 4K, ships in ~3 KB (+ Three.js on this case), and consumes zero community bandwidth after load. SVG can’t contact that after getting 1000’s of nodes, and autoplay video is 2 orders of magnitude heavier on bandwidth, CPU and battery. Briefly: that is the in all probability one of many lightest fully-interactive background impact you’ll be able to construct on the open internet immediately.