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
- 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).
npm i dither-flowOr skip the install entirely — it's one ES module with no dependencies.
<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.
| 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({ ... }).
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 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.
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.
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.
MIT © Caleb Lemos
