Bit-Math Bayer8 Dither
Ordered dithering with the threshold matrix computed from the fragment coordinate instead of sampled from a texture. Six integer operations reproduce the classic 8x8 Bayer matrix exactly, which turns a banding-prone gradient into a stable newsprint texture that holds at any zoom and costs less than the texture fetch it replaces.
Bit-Math Bayer8 Dither
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/bayer8-ordered-dither.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Bit-Math Bayer8 Dither
Ordered dithering with the threshold matrix computed from the fragment coordinate instead of sampled from a texture. Six integer operations reproduce the classic 8x8 Bayer matrix exactly, which turns a banding-prone gradient into a stable newsprint texture that holds at any zoom and costs less than the texture fetch it replaces.
Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.
## What it does
A smooth gradient crushed to five levels through an 8x8 ordered matrix derived from six integer ops.
- Category: webgl
- Runtime cost: webgl
- Dependencies: none beyond React
## How it works
1. The Bayer matrix is defined recursively as `M2 = [[0,2],[3,1]]` and `M2n(x,y) = 4*Mn(x mod n, y mod n) + M2(x div n, y div n)`. Writing M2 in binary (0=00, 2=10, 3=11, 1=01) collapses it to the closed form `M2(a,b) = 2*(a^b) + b`.
2. Unrolling that recursion three times gives `M8(x,y) = 16*M2(x0,y0) + 4*M2(x1,y1) + M2(x2,y2)` where xN is bit N of x, and substituting the closed form yields the whole matrix as one expression: `32*(x0^y0) + 16*y0 + 8*(x1^y1) + 4*y1 + 2*(x2^y2) + y2`.
3. Read as bits that is the low three bits of `x ^ y` and of `y`, interleaved and reversed — the LEAST significant coordinate bit drives the MOST significant threshold bit, which is precisely what makes the pattern maximally dispersed instead of clustered. In GLSL it is `int x = c.x & 7; int y = c.y & 7;` followed by those six terms, divided by 64.
4. `gl_FragCoord.xy` is divided by `cellSize * dpr` before the matrix lookup, so the dither cell is a fixed physical size and the texture does not get four times finer on a retina display.
5. Quantisation is `floor(clamp(c + (threshold - 0.5) / steps, 0, 1) * steps + 0.5) / steps` with `steps = levels - 1`. Centring the threshold on zero and scaling it to exactly one output step is what makes the perturbation cancel out on average.
6. A `pow(g, 1.35)` gamma is applied before quantisation, because the output levels are evenly spaced in the encoded value while the eye is not — quantising the linear value alone dumps all the visible banding into the shadows.
## Constants — use these values
These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".
- `divisor = 64.0`
The matrix holds 0..63, so dividing by 64 keeps the thresholds in [0, 1). Dividing by 63 puts the top cell at exactly 1.0, which pushes one cell in every 8x8 tile a full step brighter and stamps a visible dot grid across flat areas.
- `levels = 5`
Output steps per channel. At 2 it is a pure 1-bit print and the gradient becomes texture only. Above about 12 the eye can no longer see the individual steps and the dither is doing nothing but adding noise — use it to fix banding, not as a look.
- `cellSize = 2`
CSS pixels per dither cell, so the 8x8 tile is 16 CSS px. At 1 the pattern is at the Nyquist limit of most displays and shimmers when the page scrolls; above about 6 it stops reading as print texture and starts reading as a deliberate checkerboard.
- `threshold scale = (t - 0.5) / (levels - 1)`
The perturbation must span exactly one output step. Larger and the dither adds contrast of its own — flat areas visibly sparkle. Smaller and it cannot push a value across a level boundary, so the bands survive untouched.
- `gamma = 1.35`
Applied before quantisation. Without it the evenly spaced levels bunch perceptually in the highlights and spread in the shadows, so the dark half of the frame shows obvious steps while the bright half looks smooth.
## API
```ts
function Bayer8OrderedDither(props: { levels?: number; cellSize?: number; speed?: number; dark?: readonly [number, number, number]; light?: readonly [number, number, number]; maxDpr?: number; className?: string }): JSX.Element
```
- `levels: number` — default `5`
Output steps per channel, both ends included. 2 is pure 1-bit.
- `cellSize: number` — default `2`
CSS pixels per dither cell. Multiplied by DPR internally.
- `speed: number` — default `1`
Multiplier on the sweep that animates the underlying gradient.
- `dark: readonly [number, number, number]` — default `[0.035, 0.038, 0.045]`
Darkest output colour, linear 0..1 RGB.
- `light: readonly [number, number, number]` — default `[0.9, 0.94, 0.92]`
Lightest output colour, linear 0..1 RGB.
- `maxDpr: number` — default `2`
Upper bound on device pixel ratio.
- `className: string`
Merged onto the host element.
## Reference implementation of the core
This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.
```tsx
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const LEVELS = 5;
const CELL_SIZE = 2;
const DARK: readonly [number, number, number] = [0.035, 0.038, 0.045];
const LIGHT: readonly [number, number, number] = [0.9, 0.94, 0.92];
interface Gpu {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
locations: Map<string, WebGLUniformLocation | null>;
}
function build(gl: WebGL2RenderingContext): Gpu | null {
const compile = (type: number, src: string): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error('[bayer8-ordered-dither]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
};
const vert = compile(gl.VERTEX_SHADER, BAYER8_DITHER_VERT);
const frag = compile(gl.FRAGMENT_SHADER, BAYER8_DITHER_FRAG);
const program = gl.createProgram();
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vert || !frag || !program || !vao || !buffer) return null;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[bayer8-ordered-dither]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
return { gl, program, vao, buffer, locations: new Map() };
}
function loc(gpu: Gpu, name: string): WebGLUniformLocation | null {
const cached = gpu.locations.get(name);
if (cached !== undefined) return cached;
const found = gpu.gl.getUniformLocation(gpu.program, name);
gpu.locations.set(name, found);
return found;
}
export function Bayer8OrderedDither({
levels = LEVELS,
cellSize = CELL_SIZE,
speed = 1,
dark = DARK,
light = LIGHT,
maxDpr = 2,
className,
}: Bayer8OrderedDitherProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const gpuRef = useRef<Gpu | null>(null);
const dprRef = useRef(1);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
const pointer = useSmoothPointer({ target: host, smoothing: 0.004 });
const [supported, setSupported] = useState(true);
const paramsRef = useRef({ levels, cellSize, speed, dark, light });
paramsRef.current = { levels, cellSize, speed, dark, light };
const drawRef = useRef<(elapsed: number) => void>(() => {});
drawRef.current = (elapsed: number) => {
const gpu = gpuRef.current;
if (!gpu) return;
const { gl } = gpu;
const params = paramsRef.current;
const p = pointer.current;
gl.useProgram(gpu.program);
gl.uniform2f(loc(gpu, 'u_resolution'), gl.drawingBufferWidth, gl.drawingBufferHeight);
gl.uniform1f(loc(gpu, 'u_time'), elapsed * params.speed);
gl.uniform2f(loc(gpu, 'u_pointer'), p.nx, 1 - p.ny);
gl.uniform1f(loc(gpu, 'u_levels'), Math.max(2, Math.round(params.levels)));
// Cell size is authored in CSS pixels and converted here, so the dither
// grain is the same physical size on a 1x monitor and a 3x phone.
gl.uniform1f(loc(gpu, 'u_cell'), Math.max(1, params.cellSize * dprRef.current));
gl.uniform3f(loc(gpu, 'u_dark'), params.dark[0], params.dark[1], params.dark[2]);
gl.uniform3f(loc(gpu, 'u_light'), params.light[0], params.light[1], params.light[2]);
gl.bindVertexArray(gpu.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const gpu = build(gl);
if (!gpu) {
loseContext(gl);
setSupported(false);
return;
}
gpuRef.current = gpu;
setSupported(true);
return () => {
gpuRef.current = null;
gl.deleteVertexArray(gpu.vao);
gl.deleteBuffer(gpu.buffer);
gl.deleteProgram(gpu.program);
loseContext(gl);
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const gpu = gpuRef.current;
if (!canvas || !gpu || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
dprRef.current = dpr;
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
gpu.gl.viewport(0, 0, w, h);
drawRef.current(0);
}, [width, height, maxDpr, supported, reduced]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((_dt, elapsed) => drawRef.current(elapsed), running);
```
## Requirements
These are not optional. A version of this component that skips any of them is
not finished.
**Cleanup.** Deletes the VAO, vertex buffer and program, then `loseContext(gl)`. The rAF subscription and pointer listener are removed by their hooks.
**Reduced motion.** The rAF loop never subscribes; the sweep is frozen at t=0 and the pointer light sits at its seeded centre. One frame is drawn on mount and on every resize, so the dithered gradient is fully rendered — the texture is the point, and it is static.
**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.
**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.
## Known pitfalls — avoid these specifically
- Uploading the matrix as an 8x8 texture. It needs a texture unit, a sampler uniform, GL_NEAREST filtering and CLAMP wrapping set correctly on a non-power-of-two-safe path, and a resource to dispose — all to replace six integer instructions that are cheaper than the fetch.
- Dividing the matrix by 63 instead of 64. The top cell lands on exactly 1.0, that one cell per tile is pushed a full level brighter than its neighbours, and flat regions grow a faint regular dot grid that only shows up on a real screen.
- Comparing against the raw matrix value with `step(0.0, x)`. The matrix contains a genuine zero cell, so the comparison passes there even when it should not, and every 8x8 tile gets one stuck pixel. Bias the comparison by about 0.001 if you use a threshold form rather than the quantise form here.
- Indexing the matrix with `gl_FragCoord` directly. The cell then measures one device pixel, so the same component looks twice as coarse on a 1x monitor as on a 2x one. Divide by `cellSize * dpr` and the texture is stable.
- Quantising the linear value with no gamma. The steps are uniform in code and non-uniform to the eye, so the shadows band visibly while the highlights look fine and the fix gets misdiagnosed as "not enough levels".
- Leaving the raw GL objects behind on unmount. There is no `dispose()` here — the VAO, buffer and program are deleted by hand and `loseContext(gl)` returns the context, without which a page that mounts this repeatedly starts blanking its oldest canvases.
## Notes
- The alternative implementation, a nested `mix`/`step` ladder, is branchless and works on WebGL1. This one needs WebGL2 for integer bit operators but is roughly half the instruction count.
- The same six lines dither any signal — swap the gradient in `main` for a scene texture and this becomes a post-process.
## Definition of done
The gradient shows no smooth ramp anywhere — every transition is a field of ordered dots resolving between five flat levels. Zoom the browser to 200% and the dot pitch doubles with the page instead of staying at one device pixel. Screenshot two consecutive frames with the pointer still: the dot pattern is byte-identical, because the matrix depends only on position.
When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
Source
9 files, exactly as installed.
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useSmoothPointer } from '@/hooks/use-smooth-pointer';
import { useVisibility } from '@/hooks/use-visibility';
import { hasWebGL, loseContext, pixelRatio } from '@/lib/webgl';
import {
BAYER8_DITHER_FRAG,
BAYER8_DITHER_VERT,
} from '@/components/motion/shaders/bayer8-ordered-dither';
/* ==========================================================================
Bit-Math Bayer8 Ordered Dither
--------------------------------------------------------------------------
Raw WebGL2. A smooth gradient is reduced to a handful of levels through an
8x8 ordered-dither matrix that is DERIVED from gl_FragCoord with six integer
ops — no lookup texture, no 64-float uniform array, no branches. The full
derivation lives at the top of the shader file.
Uploading a 8x8 texture would mean a second texture unit, a sampler, a
GL_NEAREST filter that some drivers still get wrong on non-power-of-two
viewports, and a resource to dispose. Six integer instructions cost less
than the texture fetch they replace.
========================================================================== */
export interface Bayer8OrderedDitherProps {
/** Output steps per channel, both ends included. 2 is pure 1-bit. */
levels?: number;
/** CSS pixels per dither cell. Scaled by DPR so the texture is stable. */
cellSize?: number;
/** Multiplier on the sweep. */
speed?: number;
/** Darkest output colour, linear 0..1 RGB. */
dark?: readonly [number, number, number];
/** Lightest output colour, linear 0..1 RGB. */
light?: readonly [number, number, number];
maxDpr?: number;
className?: string;
}
// #region critical
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const LEVELS = 5;
const CELL_SIZE = 2;
const DARK: readonly [number, number, number] = [0.035, 0.038, 0.045];
const LIGHT: readonly [number, number, number] = [0.9, 0.94, 0.92];
interface Gpu {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
locations: Map<string, WebGLUniformLocation | null>;
}
function build(gl: WebGL2RenderingContext): Gpu | null {
const compile = (type: number, src: string): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error('[bayer8-ordered-dither]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
};
const vert = compile(gl.VERTEX_SHADER, BAYER8_DITHER_VERT);
const frag = compile(gl.FRAGMENT_SHADER, BAYER8_DITHER_FRAG);
const program = gl.createProgram();
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vert || !frag || !program || !vao || !buffer) return null;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[bayer8-ordered-dither]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
return { gl, program, vao, buffer, locations: new Map() };
}
function loc(gpu: Gpu, name: string): WebGLUniformLocation | null {
const cached = gpu.locations.get(name);
if (cached !== undefined) return cached;
const found = gpu.gl.getUniformLocation(gpu.program, name);
gpu.locations.set(name, found);
return found;
}
export function Bayer8OrderedDither({
levels = LEVELS,
cellSize = CELL_SIZE,
speed = 1,
dark = DARK,
light = LIGHT,
maxDpr = 2,
className,
}: Bayer8OrderedDitherProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const gpuRef = useRef<Gpu | null>(null);
const dprRef = useRef(1);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
const pointer = useSmoothPointer({ target: host, smoothing: 0.004 });
const [supported, setSupported] = useState(true);
const paramsRef = useRef({ levels, cellSize, speed, dark, light });
paramsRef.current = { levels, cellSize, speed, dark, light };
const drawRef = useRef<(elapsed: number) => void>(() => {});
drawRef.current = (elapsed: number) => {
const gpu = gpuRef.current;
if (!gpu) return;
const { gl } = gpu;
const params = paramsRef.current;
const p = pointer.current;
gl.useProgram(gpu.program);
gl.uniform2f(loc(gpu, 'u_resolution'), gl.drawingBufferWidth, gl.drawingBufferHeight);
gl.uniform1f(loc(gpu, 'u_time'), elapsed * params.speed);
gl.uniform2f(loc(gpu, 'u_pointer'), p.nx, 1 - p.ny);
gl.uniform1f(loc(gpu, 'u_levels'), Math.max(2, Math.round(params.levels)));
// Cell size is authored in CSS pixels and converted here, so the dither
// grain is the same physical size on a 1x monitor and a 3x phone.
gl.uniform1f(loc(gpu, 'u_cell'), Math.max(1, params.cellSize * dprRef.current));
gl.uniform3f(loc(gpu, 'u_dark'), params.dark[0], params.dark[1], params.dark[2]);
gl.uniform3f(loc(gpu, 'u_light'), params.light[0], params.light[1], params.light[2]);
gl.bindVertexArray(gpu.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const gpu = build(gl);
if (!gpu) {
loseContext(gl);
setSupported(false);
return;
}
gpuRef.current = gpu;
setSupported(true);
return () => {
gpuRef.current = null;
gl.deleteVertexArray(gpu.vao);
gl.deleteBuffer(gpu.buffer);
gl.deleteProgram(gpu.program);
loseContext(gl);
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const gpu = gpuRef.current;
if (!canvas || !gpu || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
dprRef.current = dpr;
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
gpu.gl.viewport(0, 0, w, h);
drawRef.current(0);
}, [width, height, maxDpr, supported, reduced]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((_dt, elapsed) => drawRef.current(elapsed), running);
// #endregion critical
return (
<div ref={host} className={cn('relative h-full w-full overflow-hidden', className)}>
{supported ? (
<canvas ref={canvasRef} className="block h-full w-full" />
) : (
<div
aria-hidden
className="h-full w-full bg-bg"
style={{
backgroundImage:
'radial-gradient(circle at 1px 1px, var(--k-line-strong) 1px, transparent 0), linear-gradient(180deg, var(--k-surface), var(--k-bg))',
backgroundSize: '4px 4px, 100% 100%',
}}
/>
)}
</div>
);
}
/* ==========================================================================
Bit-Math Bayer8 Dither — GLSL ES 3.00
--------------------------------------------------------------------------
The 8x8 ordered-dither matrix is DERIVED from the coordinate with six
integer operations. No texture, no 64-entry constant array, no branches.
Derivation. The Bayer matrix is defined recursively:
M2 = [[0, 2], [3, 1]]
M2n(x, y) = 4 * Mn(x mod n, y mod n) + M2(x div n, y div n)
Write M2 in binary — 0=00, 2=10, 3=11, 1=01 — and a pattern appears:
M2(a, b) = 2 * (a ^ b) + b
Unrolling the recursion three times for the 8x8 case gives
M8(x, y) = 16 * M2(x0, y0) + 4 * M2(x1, y1) + M2(x2, y2)
where xN is bit N of x. Substituting the closed form for M2:
M8(x, y) = 32*(x0^y0) + 16*y0
+ 8*(x1^y1) + 4*y1
+ 2*(x2^y2) + y2
Read as bits, that is the three-bit values of (x ^ y) and y interleaved and
REVERSED — the least significant bit of the coordinate controls the most
significant bit of the threshold, which is exactly what makes the pattern
maximally dispersed rather than clustered.
========================================================================== */
export const BAYER8_DITHER_VERT = `#version 300 es
in vec2 a_pos;
out vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
export const BAYER8_DITHER_FRAG = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_pointer;
uniform float u_levels; /* output steps per channel, including both ends */
uniform float u_cell; /* device pixels per dither cell */
uniform vec3 u_dark;
uniform vec3 u_light;
/* Threshold in [0, 1) for this cell. See the derivation at the top of the
file. '& 7' is the modulo — the matrix tiles every 8 cells on both axes. */
float bayer8(vec2 cellCoord) {
ivec2 c = ivec2(floor(cellCoord));
int x = c.x & 7;
int y = c.y & 7;
int v = 32 * ((x & 1) ^ (y & 1))
+ 16 * (y & 1)
+ 8 * (((x >> 1) & 1) ^ ((y >> 1) & 1))
+ 4 * ((y >> 1) & 1)
+ 2 * (((x >> 2) & 1) ^ ((y >> 2) & 1))
+ ((y >> 2) & 1);
/* Divide by 64, not 63. The matrix holds 0..63, and dividing by 63 would
make the top cell land on exactly 1.0, which pushes that one cell in every
tile a full step brighter and stamps a visible dot grid into flat areas. */
return float(v) / 64.0;
}
/* Quantise to 'levels' evenly spaced values, offsetting by the cell threshold
first. '(t - 0.5) / steps' centres the perturbation on zero and scales it to
exactly one output step: any larger and the dither injects contrast of its
own, any smaller and the bands survive. */
vec3 quantise(vec3 c, float threshold, float levels) {
float steps = max(levels - 1.0, 1.0);
vec3 shifted = clamp(c + (threshold - 0.5) / steps, 0.0, 1.0);
return floor(shifted * steps + 0.5) / steps;
}
void main() {
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
vec2 p = v_uv;
p.x *= aspect;
vec2 m = u_pointer;
m.x *= aspect;
/* A deliberately smooth, banding-prone field: a wide radial falloff around
the pointer, a vertical ramp, and a slow horizontal sweep. Every one of
these is the kind of gradient that shows 8-bit staircase edges. */
float g = 1.0 - smoothstep(0.0, 1.15, distance(p, m));
g = mix(g, 1.0 - v_uv.y, 0.42);
g += 0.09 * sin(v_uv.x * 3.1 + u_time * 0.25);
g = clamp(g, 0.0, 1.0);
/* Gamma before quantisation. The level steps are evenly spaced in the
encoded value, and the eye is not, so quantising the linear value alone
puts all the visible banding in the shadows. */
g = pow(g, 1.35);
vec3 col = mix(u_dark, u_light, g);
/* Dividing gl_FragCoord by the cell size is what decouples the dither
pattern from the pixel grid, so the texture stays the same physical size
on a 1x and a 3x display. */
float threshold = bayer8(gl_FragCoord.xy / max(u_cell, 1.0));
fragColor = vec4(quantise(col, threshold, u_levels), 1.0);
}
`;
/* ==========================================================================
Frame-rate-independent interpolation.
--------------------------------------------------------------------------
The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice
as fast on a 120 Hz display as on a 60 Hz one, so the same code feels
different on different hardware and visibly speeds up when the browser
drops frames. `damp` fixes it by raising the retention factor to the power
of elapsed time, which is the exact continuous form of the same curve.
========================================================================== */
/**
* Fraction of the remaining distance to close this frame.
*
* @param smoothing Fraction of the gap that REMAINS after one second.
* Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft,
* 0.5 ≈ slow drift.
* @param dt Seconds elapsed since the previous frame.
*/
export function dampFactor(smoothing: number, dt: number): number {
return 1 - Math.pow(smoothing, dt);
}
/** Move `current` toward `target` at a rate independent of frame rate. */
export function damp(
current: number,
target: number,
smoothing: number,
dt: number,
): number {
return current + (target - current) * dampFactor(smoothing, dt);
}
/**
* The per-frame lerp constant an author measured at 60fps, converted into the
* `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame"
* be ported without re-tuning by eye:
*
* damp(x, t, perFrameToSmoothing(0.1), dt)
*/
export function perFrameToSmoothing(alpha: number, baseFps = 60): number {
return Math.pow(1 - alpha, baseFps);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
export function inverseLerp(a: number, b: number, v: number): number {
return a === b ? 0 : (v - a) / (b - a);
}
export function mapRange(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
doClamp = true,
): number {
const t = inverseLerp(inMin, inMax, v);
const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t);
return r;
}
/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp(inverseLerp(edge0, edge1, x), 0, 1);
return t * t * (3 - 2 * t);
}
/**
* Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at
* distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and
* no branches, which is why carousels that use it feel analogue rather than
* stepped.
*/
export function gaussian(distance: number, falloff = 1): number {
const d = distance * falloff;
return Math.exp(-(d * d));
}
'use client';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One shared requestAnimationFrame loop.
--------------------------------------------------------------------------
Forty components each calling requestAnimationFrame means forty callbacks
the browser schedules separately and forty chances to read layout after a
write. One loop, one ordered pass, one place to clamp delta time.
Delta is clamped to 100 ms. Without that, returning to a backgrounded tab
delivers a multi-second dt and every physics integration in the page
explodes on the first frame back.
========================================================================== */
export type RafCallback = (dt: number, elapsed: number) => void;
const callbacks = new Set<RafCallback>();
let frame = 0;
let last = 0;
let start = 0;
const MAX_DT = 0.1;
const DEFAULT_DT = 1 / 60;
function tick(now: number) {
frame = requestAnimationFrame(tick);
const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT);
last = now;
const elapsed = (now - start) / 1000;
// Copy: a callback may unsubscribe itself (or another) mid-iteration.
for (const cb of Array.from(callbacks)) {
if (callbacks.has(cb)) cb(dt, elapsed);
}
}
function subscribe(cb: RafCallback) {
callbacks.add(cb);
if (frame === 0) {
start = performance.now();
last = 0;
frame = requestAnimationFrame(tick);
}
return () => {
callbacks.delete(cb);
if (callbacks.size === 0 && frame !== 0) {
cancelAnimationFrame(frame);
frame = 0;
last = 0;
}
};
}
/**
* Run `cb` every frame while `enabled`.
*
* The callback is held in a ref, so it does not need to be memoised and the
* subscription is never torn down just because the closure changed identity.
*/
export function useRaf(cb: RafCallback, enabled = true): void {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
if (!enabled) return;
return subscribe((dt, elapsed) => ref.current(dt, elapsed));
}, [enabled]);
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
prefers-reduced-motion, SSR-safe.
--------------------------------------------------------------------------
useSyncExternalStore rather than useState + useEffect: the server snapshot
is an explicit `false`, so the markup React renders on the server and the
markup it hydrates with agree, and the real value lands in the same commit
as hydration instead of one frame later. A useEffect version flashes the
animated variant before removing it — the exact thing the user asked not to
see.
Reduced motion is not "duration: 0". For most components here it means a
different render: the final state, a static poster, or a paused frame.
========================================================================== */
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
function getSnapshot() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/** Generic media-query hook, same SSR guarantees. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
},
() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
},
() => false,
);
}
'use client';
import { useEffect, useState, type RefObject } from 'react';
/* ==========================================================================
Element size, from one shared ResizeObserver.
--------------------------------------------------------------------------
Sized from the element rather than the window because a preview in a grid
cell resizes when the grid reflows, not when the viewport does. Reading
`window.innerWidth` is the reason so many canvas components are correct on
load and wrong after a sidebar opens.
========================================================================== */
export interface Size {
width: number;
height: number;
}
type Handler = (size: Size) => void;
let observer: ResizeObserver | null = null;
const handlers = new Map<Element, Handler>();
function ensureObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
observer ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.contentBoxSize?.[0];
const size: Size = box
? { width: box.inlineSize, height: box.blockSize }
: { width: entry.contentRect.width, height: entry.contentRect.height };
handlers.get(entry.target)?.(size);
}
});
return observer;
}
export function useResizeObserver(ref: RefObject<Element | null>): Size {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
const ro = ensureObserver();
if (!el || !ro) return;
// Seed synchronously: waiting for the first observer callback leaves one
// frame where a canvas is sized 0x0, which some drivers log as an error.
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
handlers.set(el, (next) =>
setSize((prev) =>
prev.width === next.width && prev.height === next.height ? prev : next,
),
);
ro.observe(el);
return () => {
handlers.delete(el);
ro.unobserve(el);
};
}, [ref]);
return size;
}
'use client';
import { useEffect, useRef, type RefObject } from 'react';
import { useRaf } from '@/hooks/use-raf';
import { damp } from '@/lib/damp';
/* ==========================================================================
Pointer position, smoothed, in several coordinate spaces at once.
--------------------------------------------------------------------------
Almost every cursor effect wants the same four things and computes them
slightly differently each time: client pixels, element-local pixels, 0..1
normalised, and -1..1 NDC for shaders. Producing all four from one damped
source keeps a cursor dot and the shader it drives exactly in step.
Exposed via ref for the same reason as scroll velocity: per-frame values
must not re-render.
========================================================================== */
export interface PointerState {
/** Viewport pixels. */
clientX: number;
clientY: number;
/** Pixels relative to the target element's top-left. */
x: number;
y: number;
/** 0..1 across the element. */
nx: number;
ny: number;
/** -1..1, y up. Feed straight into a shader uniform. */
ndcX: number;
ndcY: number;
/** Whether the pointer has been seen. Nothing should render before. */
active: boolean;
}
export interface UseSmoothPointerOptions {
/** Fraction of the gap remaining after one second. Lower = tighter. */
smoothing?: number;
/** Listen on this element rather than the window. */
target?: RefObject<HTMLElement | null>;
/** Follow touch drags as well as mouse moves. */
touch?: boolean;
}
function makeInitialState(): PointerState {
return {
clientX: 0,
clientY: 0,
x: 0,
y: 0,
nx: 0.5,
ny: 0.5,
ndcX: 0,
ndcY: 0,
active: false,
};
}
export function useSmoothPointer(
options: UseSmoothPointerOptions = {},
): { current: PointerState } {
const { smoothing = 0.001, target, touch = true } = options;
const state = useRef<PointerState>(makeInitialState());
// `null` means "no real pointer yet", which is distinct from a legitimate
// pointer at (0, 0) in the top-left corner.
const targetPos = useRef<{ clientX: number; clientY: number } | null>(null);
const seeded = useRef(false);
// Options the listener needs but that must not re-subscribe it on change.
const touchRef = useRef(touch);
touchRef.current = touch;
useEffect(() => {
const el = target?.current ?? null;
const node: HTMLElement | Window = el ?? window;
const onMove = (event: Event) => {
const e = event as PointerEvent;
if (!touchRef.current && e.pointerType === 'touch') return;
targetPos.current = { clientX: e.clientX, clientY: e.clientY };
};
node.addEventListener('pointermove', onMove, { passive: true });
return () => node.removeEventListener('pointermove', onMove);
// `target` is a ref object; its identity is stable for the component's
// lifetime, so this subscribes once.
}, [target]);
useRaf((dt) => {
const el = target?.current ?? null;
const rect = el
? el.getBoundingClientRect()
: {
left: 0,
top: 0,
width: window.innerWidth,
height: window.innerHeight,
};
const pointer = targetPos.current;
if (pointer === null) {
// Park at the centre until a real pointer arrives, so the first move
// eases out from the middle rather than snapping in from (0, 0). Done
// once — re-centring every frame would fight a slow first movement.
if (!seeded.current && rect.width > 0 && rect.height > 0) {
seeded.current = true;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
state.current = {
...state.current,
clientX: cx,
clientY: cy,
x: cx - rect.left,
y: cy - rect.top,
};
}
return;
}
const clientX = damp(state.current.clientX, pointer.clientX, smoothing, dt);
const clientY = damp(state.current.clientY, pointer.clientY, smoothing, dt);
const x = clientX - rect.left;
const y = clientY - rect.top;
const nx = rect.width > 0 ? x / rect.width : 0.5;
const ny = rect.height > 0 ? y / rect.height : 0.5;
state.current = {
clientX,
clientY,
x,
y,
nx,
ny,
ndcX: nx * 2 - 1,
ndcY: -(ny * 2 - 1),
active: true,
};
});
return state;
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
Document visibility, shared.
--------------------------------------------------------------------------
A backgrounded tab still runs `setInterval`, still holds WebGL contexts,
and — on some platforms — still gets throttled rAF ticks with multi-second
deltas. Anything expensive should stop when the tab is hidden and resume
when it comes back, rather than trying to catch up.
`useSyncExternalStore` with an explicit server snapshot so SSR markup and
the first client render agree.
========================================================================== */
function subscribe(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
function getSnapshot(): boolean {
return document.visibilityState === 'visible';
}
/** Assume visible on the server: the alternative flashes a paused state. */
function getServerSnapshot(): boolean {
return true;
}
export function useVisibility(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
import type * as THREE from 'three';
/* ==========================================================================
WebGL context lifecycle.
--------------------------------------------------------------------------
Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on
low-end devices). Creating the 17th does not fail — it fires
`webglcontextlost` on the OLDEST one, so an unrelated component elsewhere
on the page goes black. Same-origin iframes share the renderer process, so
isolating previews in iframes does not raise the ceiling.
The only real fix is to release contexts on unmount, and `renderer.dispose()`
ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself
alive until GC decides otherwise, which may be never. `forceContextLoss()`
is the call that actually hands it back.
========================================================================== */
/**
* Fully release a three.js renderer. Call this in the `useEffect` cleanup of
* any component that constructs a `WebGLRenderer` — the preview host drives
* unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal
* path and nothing component-specific is required.
*/
export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void {
if (!renderer) return;
// Stop the loop first: a queued frame that renders into a lost context
// throws, and the throw escapes the cleanup function.
renderer.setAnimationLoop(null);
renderer.dispose();
renderer.forceContextLoss();
const canvas = renderer.domElement;
if (canvas) {
// Zeroing the backing store releases the drawing buffer immediately
// instead of holding width×height×4 bytes until the node is collected.
canvas.width = 0;
canvas.height = 0;
}
}
/**
* Release a raw WebGL context (ogl, or hand-written GL). The extension is the
* only portable way to do it; it is absent in some headless configurations,
* hence the optional call.
*/
export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void {
if (!gl) return;
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
/** Recursively dispose geometries, materials and textures under a root. */
export function disposeObject(root: THREE.Object3D | null | undefined): void {
if (!root) return;
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
if (!material) return;
const materials = Array.isArray(material) ? material : [material];
for (const m of materials) {
for (const value of Object.values(m as unknown as Record<string, unknown>)) {
if (value && typeof value === 'object' && 'isTexture' in value) {
(value as THREE.Texture).dispose();
}
}
m.dispose?.();
}
});
}
/**
* Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and
* the cost is quadratic; on a 3x phone an uncapped full-screen shader is the
* single most common cause of a janky first paint.
*/
export function pixelRatio(max = 2): number {
if (typeof window === 'undefined') return 1;
return Math.min(window.devicePixelRatio || 1, max);
}
/**
* Whether WebGL is actually available. Some browsers expose the constructor
* but fail to create a context (blocklisted GPU, no hardware acceleration);
* a component that assumes success renders a permanently blank box.
*/
export function hasWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl2') ??
canvas.getContext('webgl') ??
canvas.getContext('experimental-webgl');
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
return true;
} catch {
return false;
}
}
API
Every parameter, its type and what happens when you change it.
function Bayer8OrderedDither(props: { levels?: number; cellSize?: number; speed?: number; dark?: readonly [number, number, number]; light?: readonly [number, number, number]; maxDpr?: number; className?: string }): JSX.Element| Parameter | Type | Default | Description |
|---|---|---|---|
| levels | number | = 5 | Output steps per channel, both ends included. 2 is pure 1-bit. |
| cellSize | number | = 2 | CSS pixels per dither cell. Multiplied by DPR internally. |
| speed | number | = 1 | Multiplier on the sweep that animates the underlying gradient. |
| dark | readonly [number, number, number] | = [0.035, 0.038, 0.045] | Darkest output colour, linear 0..1 RGB. |
| light | readonly [number, number, number] | = [0.9, 0.94, 0.92] | Lightest output colour, linear 0..1 RGB. |
| maxDpr | number | = 2 | Upper bound on device pixel ratio. |
| className | string | — | Merged onto the host element. |
How it works
The technique in order, not the code in order.
- 01
The Bayer matrix is defined recursively as
M2 = [[0,2],[3,1]]andM2n(x,y) = 4*Mn(x mod n, y mod n) + M2(x div n, y div n). Writing M2 in binary (0=00, 2=10, 3=11, 1=01) collapses it to the closed formM2(a,b) = 2*(a^b) + b. - 02
Unrolling that recursion three times gives
M8(x,y) = 16*M2(x0,y0) + 4*M2(x1,y1) + M2(x2,y2)where xN is bit N of x, and substituting the closed form yields the whole matrix as one expression:32*(x0^y0) + 16*y0 + 8*(x1^y1) + 4*y1 + 2*(x2^y2) + y2. - 03
Read as bits that is the low three bits of
x ^ yand ofy, interleaved and reversed — the LEAST significant coordinate bit drives the MOST significant threshold bit, which is precisely what makes the pattern maximally dispersed instead of clustered. In GLSL it isint x = c.x & 7; int y = c.y & 7;followed by those six terms, divided by 64. - 04
gl_FragCoord.xyis divided bycellSize * dprbefore the matrix lookup, so the dither cell is a fixed physical size and the texture does not get four times finer on a retina display. - 05
Quantisation is
floor(clamp(c + (threshold - 0.5) / steps, 0, 1) * steps + 0.5) / stepswithsteps = levels - 1. Centring the threshold on zero and scaling it to exactly one output step is what makes the perturbation cancel out on average. - 06
A
pow(g, 1.35)gamma is applied before quantisation, because the output levels are evenly spaced in the encoded value while the eye is not — quantising the linear value alone dumps all the visible banding into the shadows.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| divisor | = 64.0 | The matrix holds 0..63, so dividing by 64 keeps the thresholds in [0, 1). Dividing by 63 puts the top cell at exactly 1.0, which pushes one cell in every 8x8 tile a full step brighter and stamps a visible dot grid across flat areas. |
| levels | = 5 | Output steps per channel. At 2 it is a pure 1-bit print and the gradient becomes texture only. Above about 12 the eye can no longer see the individual steps and the dither is doing nothing but adding noise — use it to fix banding, not as a look. |
| cellSize | = 2 | CSS pixels per dither cell, so the 8x8 tile is 16 CSS px. At 1 the pattern is at the Nyquist limit of most displays and shimmers when the page scrolls; above about 6 it stops reading as print texture and starts reading as a deliberate checkerboard. |
| threshold scale | = (t - 0.5) / (levels - 1) | The perturbation must span exactly one output step. Larger and the dither adds contrast of its own — flat areas visibly sparkle. Smaller and it cannot push a value across a level boundary, so the bands survive untouched. |
| gamma | = 1.35 | Applied before quantisation. Without it the evenly spaced levels bunch perceptually in the highlights and spread in the shadows, so the dark half of the frame shows obvious steps while the bright half looks smooth. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The rAF loop never subscribes; the sweep is frozen at t=0 and the pointer light sits at its seeded centre. One frame is drawn on mount and on every resize, so the dithered gradient is fully rendered — the texture is the point, and it is static.
Cleanup
Deletes the VAO, vertex buffer and program, then loseContext(gl). The rAF subscription and pointer listener are removed by their hooks.
Definition of done
The gradient shows no smooth ramp anywhere — every transition is a field of ordered dots resolving between five flat levels. Zoom the browser to 200% and the dot pitch doubles with the page instead of staying at one device pixel. Screenshot two consecutive frames with the pointer still: the dot pattern is byte-identical, because the matrix depends only on position.
Pitfalls
- Uploading the matrix as an 8x8 texture. It needs a texture unit, a sampler uniform, GL_NEAREST filtering and CLAMP wrapping set correctly on a non-power-of-two-safe path, and a resource to dispose — all to replace six integer instructions that are cheaper than the fetch.
- Dividing the matrix by 63 instead of 64. The top cell lands on exactly 1.0, that one cell per tile is pushed a full level brighter than its neighbours, and flat regions grow a faint regular dot grid that only shows up on a real screen.
- Comparing against the raw matrix value with
step(0.0, x). The matrix contains a genuine zero cell, so the comparison passes there even when it should not, and every 8x8 tile gets one stuck pixel. Bias the comparison by about 0.001 if you use a threshold form rather than the quantise form here. - Indexing the matrix with
gl_FragCoorddirectly. The cell then measures one device pixel, so the same component looks twice as coarse on a 1x monitor as on a 2x one. Divide bycellSize * dprand the texture is stable. - Quantising the linear value with no gamma. The steps are uniform in code and non-uniform to the eye, so the shadows band visibly while the highlights look fine and the fix gets misdiagnosed as "not enough levels".
- Leaving the raw GL objects behind on unmount. There is no
dispose()here — the VAO, buffer and program are deleted by hand andloseContext(gl)returns the context, without which a page that mounts this repeatedly starts blanking its oldest canvases.
Notes
- The alternative implementation, a nested
mix/stepladder, is branchless and works on WebGL1. This one needs WebGL2 for integer bit operators but is roughly half the instruction count. - The same six lines dither any signal — swap the gradient in
mainfor a scene texture and this becomes a post-process.