Free course recommendation: Master JavaScript animation with GSAP through 34 free video lessons, step-by-step projects, and hands-on demos. Enroll now →
User experience relies on small, thoughtful details that fit well into the overall design without overpowering the user. This balance can be tricky, especially with technologies like WebGL. While they can create amazing visuals, they can also become too complicated and distracting if not handled carefully.
One subtle but effective technique is the Bayer Dithering Pattern. For example, JetBrains’ recent Junie campaign page uses this approach to craft an immersive and engaging atmosphere that remains visually balanced and accessible.
In this tutorial, I’ll introduce you to the Bayer Dithering Pattern. I’ll explain what it is, how it works, and how you can apply it to your own web projects to enhance visual depth without overpowering the user experience.
Bayer Dithering
The Bayer pattern is a type of ordered dithering, which lets you simulate gradients and depth using a fixed matrix.

If we scale this matrix appropriately, we can target specific values and create basic patterns.

Here’s a simple example:
// 2×2 Bayer matrix pattern: returns a value 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) continue; // 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 = speed * 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
}
Try to click on the CodePen bellow:
See the Pen Untitled by zavalit (@zavalit) on CodePen.
Final Thoughts
Because the entire Bayer-dither background is generated in a single GPU pass, it renders in under 0.2 ms even at 4K, ships in ~3 KB (+ Three.js in this case), and consumes zero network bandwidth after load. SVG can’t touch that once you have thousands of nodes, and autoplay video is two orders of magnitude heavier on bandwidth, CPU and battery. In short: this is the probably one of the lightest fully-interactive background effect you can build on the open web today.