A feature list is the most boring section on any site: five titles, five sentences, maybe five icons. I wanted hovering a title to feel like flipping to a page in a printed book, with its own inks, instead of a gradient blob fading in. The trick is about 30 lines of GLSL, and the part that matters is one line.
Here is the whole technique. You can ship it tonight without buying anything.
The idea: three inks, no gradients
A smooth gradient is what makes WebGL in UI look cheap. Print doesn't have gradients; it has a few inks and dots. So instead of blending between colours, we quantize a noise field to exactly three colours and let a dither pattern fake the midtones.
Three pieces:
- A flowing noise field (fractal Brownian motion, warped once by itself) that gives the liquid shape.
- A dither that turns the smooth value into one of three levels per cell.
- Big cells, so you can see the dots. That is the part that makes it look like a riso print instead of a JPEG artifact.
The fragment shader
precision highp float;
uniform vec2 u_res; // canvas size in device pixels
uniform float u_time;
uniform float u_cell; // dither cell size in device pixels
uniform vec3 u_c0; uniform vec3 u_c1; uniform vec3 u_c2; // dark, mid, light
vec2 hash2(vec2 p){
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453);
}
float noise(vec2 p){
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3. - 2. * f);
float a = dot(hash2(i) - .5, f);
float b = dot(hash2(i + vec2(1, 0)) - .5, f - vec2(1, 0));
float c = dot(hash2(i + vec2(0, 1)) - .5, f - vec2(0, 1));
float d = dot(hash2(i + vec2(1, 1)) - .5, f - vec2(1, 1));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.;
}
float fbm(vec2 p){
float v = 0., a = .5;
for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2. + 7.3; a *= .5; }
return v;
}
// Interleaved gradient noise: a cheap, stable dither threshold per cell.
float ign(vec2 px){ return fract(52.9829189 * fract(0.06711056 * px.x + 0.00583715 * px.y)); }
void main(){
// 1. Snap to big cells and sample at the cell centre.
vec2 ci = floor(gl_FragCoord.xy / u_cell);
vec2 uv = (ci + .5) * u_cell / u_res;
// 2. Liquid: fbm warped by itself, drifting with time.
vec2 p = (uv - .5) * vec2(u_res.x / u_res.y, 1.) * 2.;
float t = u_time * .1;
vec2 q = vec2(fbm(p + t), fbm(p - t));
float f = fbm(p + 2. * q);
// 3. Quantize to three inks with a per-cell threshold.
float s = smoothstep(-.5, .5, f) * 2.; // 0..2
float lvl = floor(s) + step(ign(ci), fract(s)); // 0, 1 or 2
vec3 col = lvl < .5 ? u_c0 : (lvl < 1.5 ? u_c1 : u_c2);
gl_FragColor = vec4(col, 1.);
}
The line that does the work:
float lvl = floor(s) + step(ign(ci), fract(s));
s is the smooth value stretched to 0..2. floor(s) picks the base ink. fract(s) is how far we are toward the next ink, and we compare it against a threshold that differs per cell. Where the field is 30% of the way from ink 0 to ink 1, about 30% of the cells flip up. Your eye averages the dots back into a midtone, but up close it reads as print.
Interleaved gradient noise is the threshold because it is one line, has no texture lookup, and does not shimmer frame to frame the way fract(sin(...)) white noise does. Bayer matrices work too and look more "retro"; IGN looks more organic.
Three things that took longer than the shader
Cell size has to track device pixel ratio. gl_FragCoord is in device pixels. If you hardcode a 2px cell, it looks great on a Mac and like static on a 1x monitor. Set u_cell from devicePixelRatio (about 1.5 CSS px per cell is where it stops reading as noise), and always sample the noise at the cell centre, or the dots will not be solid.
Ease the colours, don't swap them. When a palette changes (hover a different item, switch to light mode), lerp the three uniforms toward the targets over a few hundred ms. Because the output is quantized, a hard swap flashes. A lerp makes the dots "melt" from one palette to the next, which is the effect people screenshot. Pointer reaction is the same idea: ease a mouse uniform and bend p toward it a little, never snap.
Respect reduced motion and offscreen. Freeze u_time when prefers-reduced-motion is on, and stop the RAF loop when the canvas leaves the viewport with an IntersectionObserver. Six of these on one page with no pausing will warm up a phone.
Mount it in React
A sketch, not the full thing:
import { useEffect, useRef } from "react";
export function DitherCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current!;
const gl = canvas.getContext("webgl", { antialias: false })!;
// compile VERT + FRAG above, create a full-screen triangle, set uniforms...
let raf = 0;
const draw = (t: number) => {
gl.uniform1f(uTime, t / 1000);
gl.drawArrays(gl.TRIANGLES, 0, 3);
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, []);
return <canvas ref={ref} className="h-full w-full" />;
}
(Compile and attribute setup omitted; it is the standard twenty lines from every WebGL hello world. uTime is the uniform location you looked up after linking.)
Where I ended up
The list itself: hovering a title dims the rest, slides it forward, and floats a dithered block beside it in that feature's own three inks. One shader, one canvas, palette lerped on hover, tap accordion on phones.
That became Shader Index in Planes, where it installs with the shadcn CLI and takes an array of { title, description, icon, colors, seed }. Planes is a paid shadcn-style registry of 130+ animated React components, Pro is a one-time $99 until Oct 15, then $199, with lifetime updates and unlimited commercial projects.
The same canvas now also sits inside a mega-menu nav, where the panel's liquid flows into whichever section you hover. Tabs, rows and an accordion strip came out of it too.
What would you dither first: a list, a card, a nav?
Top comments (0)