npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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.

dither-flow — ink stirred across the words DITHER FLOW, revealing a sunset through the dither

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 constructing

The 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.

  1. 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.
  2. 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