dither-flow
v0.1.0
Published
A 1-bit dithered flow field for the web. Cursor-stirred ink, curl-noise advection, Bayer ordered dithering — raw WebGL2, zero dependencies.
Maintainers
Readme
dither-flow
A 1-bit dithered flow field for the web. Cursor-stirred ink that swirls along curl noise, quantized through an 8×8 Bayer matrix — pure ink pixels on transparency, no grays, no dependencies, raw WebGL2 in one file.

Live demo → · seen in the wild on caleblemos.com
What it does
- Flow: a low-resolution density field advected along two octaves of curl noise (ping-pong FBOs, R16F with an R8 fallback). Splats inject density — from your pointer, a click, device tilt, or an autonomous walker — then it drifts, swirls, and decays.
- Dither: a full-resolution pass snaps the field to a chunky pixel grid through ordered Bayer dithering. Every pixel is either ink or nothing.
- Type interplay (optional): hand it a mask canvas — rasterized text,
shapes, a logo — and the ink interacts with it: crossing smoke knocks
letterforms out to paper (
pass), erodes them into the current (erode), or rolls over everything (over). - Image reveal (optional): hide an image behind the surface and the smoke
becomes a window into it — as a true 1-bit halftone (
halftone) or in full color through the dithered opening (color).
Quickstart
No build step required — it's one ES module.
<canvas id="field" style="width:100%;height:100vh"></canvas>
<script type="module">
import { DitherFlow } from "./dither-flow.js"; // from dist/
const field = new DitherFlow(document.getElementById("field"), {
reveal: "color", // "ink" | "halftone" | "color"
autoFlow: "idle", // walker takes over when the cursor rests
});
field.loadImage("/sunset.jpg");
field.bindPointer(document.body);
field.start();
</script>Or with a bundler / TypeScript: copy src/dither-flow.ts into your project —
it has zero imports.
import { DitherFlow } from "./dither-flow";Pair it with an IntersectionObserver to stop() offscreen instances; the
field already pauses itself when the tab is hidden.
Options
| option | default | what it does |
|---|---|---|
| ink / paper | #121110 / #faf9f4 | field color, and the color masked pixels knock out to |
| cell | 2 | dither cell size in device pixels — the whole look lives here |
| decay | 0.962 | density kept per frame; higher = longer trails |
| flowSpeed | 1.0 | advection speed |
| noiseScale | 4.2 | swirl frequency |
| drift | 0.06 | constant rise (+) or sink (−) |
| splatRadius / splatStrength | 0.075 / 0.85 | pointer ink size and amount |
| variant | "pass" | how the field meets the mask: pass / erode / over |
| reveal | "ink" | ink, halftone, or color (needs an image) |
| imageContrast | 1.6 | luminance contrast for the halftone reveal |
| autoFlow | "off" | on = walker from the first frame · idle = walker after 2.6 s of cursor rest |
| maxFps | 0 | frame cap (try 30 on mobile) |
| dpr | auto (≤2) | device pixel ratio |
| autoResize / autoPause | true | ResizeObserver sizing · pause on hidden tab |
Every option can be changed live with field.set({ ... }).
Methods
field.start(); field.stop(); field.destroy();
field.pointer(x, y); // CSS px, relative to the canvas box
field.pointerEnd();
field.bindPointer(element); // convenience wiring; returns an unbind fn
field.splat(x, y, radius?, strength?);
field.setGravity(x, y); // -1..1 — feed device tilt, ink slides downhill
field.setImage(imageOrCanvas); await field.loadImage(url);
field.setMask(canvas); field.clearMask();
field.idleMs(); // ms since real pointer input
DitherFlow.isSupported(); // WebGL2 check before constructingThe text-mask recipe
The signature move — type that the smoke passes through — is a mask you paint yourself, so it works with any font, layout, or language. Rasterize your real DOM text into a canvas (white on black), matching each element's computed style, and hand it over:
const mask = document.createElement("canvas");
mask.width = canvasBox.width * dpr;
mask.height = canvasBox.height * dpr;
const ctx = mask.getContext("2d");
ctx.scale(dpr, dpr);
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "#fff"; ctx.textBaseline = "top";
for (const el of document.querySelectorAll("[data-mask-me]")) {
const cs = getComputedStyle(el);
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
for (const rect of el.getClientRects()) {
ctx.fillText(el.textContent, rect.left - box.left,
rect.top - box.top + (rect.height - parseFloat(cs.fontSize)) / 2);
}
}
field.setMask(mask);Rebuild it on resize (and after document.fonts.ready), keep the real DOM
text in place underneath — it stays selectable, accessible, and indexable;
the canvas only composites with it. Set debugMask: true while aligning.
How it works
Two draw calls per frame, one fullscreen triangle each.
- Sim pass at ~1/5 resolution. The density field samples its previous frame upstream along a velocity built from two octaves of curl noise (finite-difference rotated gradient of a value-noise field — divergence-free by construction, which is what makes it read as fluid without pressure solving). Gaussian splats add ink, an exponential decay and a small linear bleed take it away, so trails dissolve instead of lingering as fog.
- Render pass at full resolution. Pixels are grouped into cells; each cell samples density once and compares it against an 8×8 Bayer threshold computed procedurally from the cell coordinates (three levels of the recursive index matrix, bit-twiddled — no texture lookup). Density above threshold is ink; everything else is transparent. The mask and hidden image sample the same cell centers, which is why the halftone reveal reads as authentic screen-printing rather than a filtered photograph.
There is deliberately no gray anywhere in the pipeline's output: the field is continuous, but the page only ever sees ink or paper.
Performance
The sim runs at 1/5 canvas resolution, so cost scales with the dither cell
budget, not the display. On phones, { maxFps: 30, dpr: 1 } is plenty — the
dither aesthetic hides both caps completely. The whole engine is a single
class, two shaders, two small FBOs.
License
MIT © Caleb Lemos
