Ordered dithering in a shader: Bayer matrices for gradients
How Bayer dithering works, a tested GLSL shader to paste, and when noise beats it for gradient banding.
Updated
Ordered dithering rounds each pixel up or down against a threshold taken from a small repeating grid, usually a 4×4 Bayer matrix. When you cut a smooth gradient down to a few colours, plain rounding leaves hard bands. With ordered dithering, neighbouring pixels round in different directions, so each patch of pixels averages out to the colour it should have been. In a fragment shader it takes four lines:
vec2 cell = floor(px / cellSize);
ivec2 b = ivec2(mod(cell, 4.0));
float threshold = (BAYER[b.y * 4 + b.x] + 0.5) / 16.0;
color = floor(clamp(color, 0.0, 1.0) * (levels - 1.0) + threshold) / (levels - 1.0);That is the dither in the gradients.sh engine, and the rest of this post explains each line, gives a complete shader you can paste, and covers the cases where you want noise instead.
Why gradients band
A screen shows 256 values per channel. A slow gradient across a wide hero, say from #101828 to #1d2939, only has 14 to 18 distinct values per channel to spread over 1,500 pixels, so each value becomes a stripe about 100 pixels wide. Your eye picks out the edges between stripes, especially in dark colours. The same thing happens, much harder, when you choose to quantise on purpose to 4 or 8 levels for a retro look.
Dithering trades those smooth-but-stepped bands for fine texture. There are two kinds:
- Random dither adds a little noise before rounding. It looks like film grain, and it changes every frame unless you seed it.
- Ordered dither compares the colour against a fixed pattern of thresholds. It looks like a regular crosshatch, it is the same every frame, and at low levels it gives the pixel-art look you know from old games and print halftones.
The Bayer matrix
A Bayer matrix holds the numbers 0 to n²−1, arranged so that consecutive thresholds land as far apart as possible. This is the 4×4 one:
const float BAYER[16] = float[16](
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0
);(value + 0.5) / 16.0 turns it into 16 thresholds spread evenly between 0 and 1, never exactly 0 or 1. Tile the grid across the screen and every 4×4 block of cells contains each threshold once.
Now quantise. With levels colours per channel there are levels - 1 steps between 0 and 1. color * steps gives a position like 2.25, a quarter of the way from step 2 to step 3. Adding the threshold and flooring sends a cell up to step 3 whenever its threshold is above 0.75, which is true for 4 of the 16 thresholds. So on a flat colour, a quarter of each 4×4 tile of cells shows step 3 and the rest shows step 2, and the tile averages to exactly 2.25.
That's the property that makes it work, and we checked it numerically: for a flat colour, a 4×4 tile of cells averages to within 1/32 of a step of the true value, for 2, 4, 8 or 256 levels. A tile that straddles the grid's edges, or a colour that changes inside the tile, can be further off; the error just averages out over a larger area. A 4×4 matrix gives 17 distinct mixes per step, enough for a smooth ramp at normal viewing distance. An 8×8 matrix gives 65 and a finer, less obvious pattern.
A complete shader
This WebGL2 fragment shader draws a blue-to-coral ramp at 4 levels per channel in 3-pixel cells. We ran it in headless Chrome: every channel value in the output is one of 0, 85, 170 and 255, and the ramp reads as a smooth blend.
#version 300 es
precision highp float;
uniform vec2 u_size;
uniform float u_levels; // colours per channel, 2 to 32
uniform float u_cell; // cell size in pixels
out vec4 outColor;
const float BAYER[16] = float[16](
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0
);
vec3 ditherOrdered(vec3 color, vec2 px) {
vec2 cell = floor(px / u_cell);
ivec2 b = ivec2(mod(cell, 4.0));
float threshold = (BAYER[b.y * 4 + b.x] + 0.5) / 16.0;
float steps = u_levels - 1.0;
return floor(clamp(color, 0.0, 1.0) * steps + threshold) / steps;
}
void main() {
vec2 px = gl_FragCoord.xy;
// Sample once per cell so each cell is one flat colour.
vec2 center = (floor(px / u_cell) + 0.5) * u_cell;
float t = center.x / u_size.x;
vec3 color = mix(vec3(0.05, 0.08, 0.35), vec3(1.0, 0.55, 0.35), t);
outColor = vec4(ditherOrdered(color, px), 1.0);
}Three details matter:
- Sample at the cell centre. With
u_cellabove 1, compute the colour once per cell, asmaindoes, so every pixel in a cell gets the same input and the cell comes out as one flat colour. Sampling per pixel and only thresholding per cell gives cells with soft edges inside them. - Dither last, in the output colour space. The steps you quantise to have to be the steps the screen stores. The gradients.sh engine blends colours in OKLab, converts to sRGB, adds grain, and only then dithers. If you dither in linear light and convert afterwards, the steps end up unevenly spaced and dark tones band again.
- Clamp before you floor. A value a hair above 1.0 plus a threshold can floor to a step past white.
Bayer, noise, or both
Ordered dither has one known weakness: the pattern is fixed to the screen. When the gradient underneath moves, the colours slide through a stationary grid, which reads as a screen door the image moves behind. That suits a retro or display look and fights a soft, photographic one.
For invisible banding fixes, use noise instead. The engine adds triangular noise (the sum of two uniform hashes, so values near zero are most likely) of one 8-bit step, 1.0 / 255.0, to every pixel whenever dither is off, even at grain 0. You can't see it as texture, but it breaks up the stripes in slow dark gradients. How to add film grain to a gradient shows that code and how to turn it into visible grain.
Two middle grounds:
- Interleaved gradient noise (Jimenez, 2014) is a one-line formula that behaves like ordered dither without a visible period, so it reads closer to blue noise:
fract(52.9829189 * fract(dot(floor(px), vec2(0.06711056, 0.00583715)))). The engine's dither-burst style picks between it and an 8×8 Bayer matrix by seed. - Palette dithering thresholds between two palette colours instead of per channel. Find the two stops the value falls between and use the Bayer threshold to choose one. The dithered areas use only your palette, with no in-between hues. Per-channel dithering can change the hue of single pixels, but on a flat colour each tile still averages out close to the original colour. The engine's dither-field style picks palette stops this way in its dithered cells, and adds a smooth blended core and soft cell seams on top.
| You want | Use |
|---|---|
| No visible banding, no visible texture | Triangular noise at 1/255 |
| A retro, pixel-art or display look | Bayer 4×4 or 8×8, 2 to 8 levels |
| A dithered look that hides its grid | Interleaved gradient noise |
| Only your brand colours on screen | Palette dither with a Bayer threshold |
| Visible film grain | Triangular noise, larger amplitude |
Scaling breaks the pattern
Dither is per-pixel detail, so anything that resamples the image destroys it. Two places this bites in a web page:
- Rendering below full resolution. Many WebGL backgrounds draw at a reduced resolution when the page is busy and let the browser upscale. A dither pattern upscaled with smoothing turns to mush. In the gradients.sh engine, a gradient with the
ditherswitch on never drops below full resolution under the shared pixel budget (soft ones can go down to half), and image smoothing is off when its frame is copied to the visible canvas. Styles with their own built-in dither, such as dither-burst, follow the normal budget unless you switchditheron. - Device pixel ratio. Cell sizes are in canvas pixels. If you want them in CSS pixels, make the canvas buffer match the CSS size and scale it up with
image-rendering: pixelated, so the browser doesn't blur the cells.
The shader cost is small: a lookup, a multiply and a floor per channel. The real cost is the full-resolution rule above, so on a large hero a gradient with dither on draws more pixels than a soft one.
Dither is fine detail, and fine detail is what video codecs spend bits on. If you export a dithered gradient as a loop, check the file size before you ship it. Background video vs animated gradient shows how much grain alone adds.
A dithered gradient in React
Every free gradients.sh component takes a dither prop that switches on the engine's 4×4 Bayer dither, with cell (1 to 32 pixels) and levels (2 to 32 per channel). This is Mesh Gradient at 4 levels in 3-pixel cells:
Four levels per channel
import { MeshGradient } from "@/components/mesh-gradient";
export function DitheredMeshDemo() {
return (
<MeshGradient
className="flex h-[360px] items-start p-8"
dither={{ enabled: true, cell: 3, levels: 4 }}
>
<h2 className="text-3xl font-semibold tracking-tight text-white">
Four levels per channel
</h2>
</MeshGradient>
);
}
Install it into any React project with a components.json (run npx shadcn@latest init first if you don't have one):
pnpm dlx shadcn@latest add https://gradients.sh/r/mesh-gradient.jsonimport { MeshGradient } from "@/components/mesh-gradient";
export function Hero() {
return (
<MeshGradient
className="h-[480px]"
dither={{ enabled: true, cell: 3, levels: 4 }}
/>
);
}At 2 levels each channel is either fully on or off, eight colours in all. Around 6 to 8 levels the image keeps its hues and the dither reads as texture. Larger cells make the pattern chunkier. The dither runs the same way on WebGPU and WebGL2.
To tune it by eye, open the editor, switch on Dither and drag the cell and levels sliders. The free presets Coldlight, Kiln and Cobalt Rise use the dither-burst style, which has its own built-in dither.
Related
- How to add film grain to a gradient covers the noise side of this post, in CSS and in a shader.
- How seamless gradient loops work explains the looping noise underneath the engine's gradients.
- WebGPU vs WebGL2 for UI backgrounds shows how the engine gets the same pixels from both APIs.
- Blue gradients in CSS measures how far a navy gradient bands across a hero, and which blend keeps blue from drifting purple.
- The grainy gradient generator starts from presets with heavy grain, if noise suits you better than a pattern.