API reference

Every function, option and type in dither-art, with the exact errors each one throws. Pictures on this page are drawn in your browser by the same build the package ships.

Install

ESM only. Node 18 or later, or any modern browser, Bun, Deno or edge runtime.

npm install dither-art

Quick start

Draw a grid of pixels from a seed, then turn it into whatever your target needs. The same seed and size always give the same pixels.

import { ditherPixels, toSVG } from "dither-art";

const pixels = ditherPixels(320, 180, "my-seed"); // Uint8Array, 1 = ink, 0 = paper
const svg = toSVG(pixels, 320, 180); // ink is currentColor, paper is transparent

document.querySelector("#cover").innerHTML = svg;

Ink defaults to currentColor, so the picture takes the colour of the text around it and follows dark mode with no extra work.

#cover { color: var(--accent); }
#cover svg { width: 100%; height: auto; }

ditherPixels

The main entry point: seed in, pixels out.

ditherPixels(width: number, height: number, seed: string, options?: DitherOptions): Uint8Array

Draws a picture and dithers it to one bit per pixel. Sizes are floored to whole numbers and clamped to at least 1, so ditherPixels(10.9, 5.2, s) gives a 10 × 5 picture.

width, height number
Size in pixels. Throws RangeError for NaN or Infinity.
seed string
Any string. The same seed gives the same picture, on every runtime.
options.scene DitherScene | Scene
A built-in name or your own function. Default: chosen from the seed.
options.method DitherMethod
How grey becomes 1-bit. Default "bayer".
options.density number
Ink multiplier. Default 1. Zero, negative or NaN gives a blank picture rather than throwing.

Returns a Uint8Array of width * height values, row by row: 1 is ink, 0 is paper.
Throws RangeError for a non-finite size, and TypeError for an unknown scene or method name — the message lists the valid ones.

toSVG

toSVG(pixels: ArrayLike<number>, width: number, height: number, options?: SvgOptions): string

Returns an SVG string: one path per row tracing each run of ink as a one-pixel stroke, in relative steps, with shape-rendering="crispEdges". It scales to any size without blurring and can go straight into HTML, an image route, or a data URL.

options.ink string
Any CSS colour. Default "currentColor".
options.paper string
Background colour. Default: none, so the paper is transparent.
options.scale number
Displayed pixels per picture pixel, written into the width and height attributes. Default 1.
options.title string
An accessible name. Without one the SVG is marked aria-hidden="true" as decoration.

Size. Dithered pictures are busy: expect roughly 40–100 KB for 320 × 180, which compresses to 3–6 KB with the gzip or Brotli your server or CDN already applies.

toRGBA

toRGBA(pixels: ArrayLike<number>, options?: RgbaOptions): Uint8ClampedArray

Turns the pixels into RGBA bytes, ready for a canvas.

options.ink Colour
"#rgb", "#rgba", "#rrggbb", "#rrggbbaa" or [r, g, b, a?] with channels 0–255. Default "#000".
options.paper Colour
Same formats. Default: transparent.

Returns a Uint8ClampedArray of 4 bytes per pixel.

const rgba = toRGBA(pixels, { ink: "#c41e3a", paper: "#fdf6e3" });
ctx.putImageData(new ImageData(rgba, width, height), 0, 0);

dither

dither(ink: ArrayLike<number>, width: number, height: number, method?: DitherMethod): Uint8Array

Dithers your own grid of ink amounts, so you can run a photo, a gradient or any greyscale field through the same methods. Values outside 0–1 are clamped, and NaN counts as paper.

Throws RangeError when ink.length is not width * height — the message states both numbers.

Scenes

Six built-ins. Leave scene out and the seed picks one; sceneForSeed(seed) tells you which it would choose. Every picture below is the seed "docs".

Dithering methods

One bit per pixel means every pixel is ink or paper. The greys are an illusion made of dots, and these are four ways to arrange them. Same seed and scene throughout.

MethodLookBehaviour
"bayer" Regular crosshatch, retro The default. The grain stays put when the size changes.
"blue-noise" Fine, even grain with no pattern Same stability as Bayer. The 64 × 64 map is built on first use, in under 100 ms.
"floyd-steinberg" Smooth tones, most detail Error diffusion: the grain reshuffles when the size changes.
"atkinson" Crisp and contrasty, like the original Macintosh Error diffusion. Very light and very dark areas go solid.

Your own scene

A scene receives seeded randomness and the picture's shape, and returns the ink amount at every point. x runs from 0 to aspect (width / height) and y from 0 at the top to 1 at the bottom, so a scene is resolution-independent.

import { ditherPixels, type Scene } from "dither-art";

const halo: Scene = (rand, { aspect, pixel }) => {
  const cx = aspect * (0.3 + rand() * 0.4);
  const r = 0.25 + rand() * 0.1;
  return (x, y) => {
    const d = Math.hypot(x - cx, y - 0.5);
    if (Math.abs(d - r) < Math.max(0.01, pixel)) return 1; // a ring at least a pixel wide
    return d < r ? 0.15 : Math.min(1, (d - r) * 1.5);
  };
};

const pixels = ditherPixels(320, 180, "my-seed", { scene: halo });
rand () => number
Seeded generator in [0, 1). Call it the same number of times for the same picture.
context.aspect number
Width / height. The field's x runs from 0 to this.
context.pixel number
The size of one output pixel in field units (1 / height). Use it to keep lines and dots at least a pixel wide so small sizes stay readable.

Other exports

sceneForSeed(seed)
The scene a seed gets when none is given.
ridges, orbs, contours, sea, dunes, planet
The built-in scenes as Scene functions, to wrap or combine.
hashSeed(string)
FNV-1a 32-bit hash. hashSeed("abc") is 440920331.
seededRandom(seed)
A mulberry32 generator of numbers in [0, 1), from a 32-bit integer seed.
blueNoiseMap()
The 64 × 64 blue-noise threshold map: a Float32Array of 4096 values in (0, 1).
BAYER
The 8 × 8 Bayer thresholds: 64 numbers in (0, 1), row by row.
DITHER_SCENES, DITHER_METHODS
The scene and method names, as readonly arrays.

Types

TypeScript 5.7 or later. All types are exported from the package root.

type DitherScene = "ridges" | "orbs" | "contours" | "sea" | "dunes" | "planet";
type DitherMethod = "bayer" | "blue-noise" | "floyd-steinberg" | "atkinson";

type Field = (x: number, y: number) => number;
type Scene = (rand: () => number, context: SceneContext) => Field;

interface SceneContext { aspect: number; pixel: number }
interface DitherOptions { scene?: DitherScene | Scene; density?: number; method?: DitherMethod }
interface SvgOptions { ink?: string; paper?: string; scale?: number; title?: string }
interface RgbaOptions { ink?: Colour; paper?: Colour }

type Colour = string | readonly [number, number, number, number?];

Recipes

Canvas

const [w, h] = [320, 180];
canvas.width = w;
canvas.height = h;
canvas.style.imageRendering = "pixelated";

const pixels = ditherPixels(w, h, "my-seed", { method: "atkinson" });
const rgba = toRGBA(pixels, { ink: "#c41e3a", paper: "#fdf6e3" });
canvas.getContext("2d").putImageData(new ImageData(rgba, w, h), 0, 0);

Server-rendered image route

const pixels = ditherPixels(600, 315, `post-${id}`);

return new Response(toSVG(pixels, 600, 315, { ink: "#111", paper: "#fff", title: "Cover art" }), {
  headers: {
    "content-type": "image/svg+xml",
    "cache-control": "public, max-age=31536000, immutable",
  },
});

The picture is a pure function of the seed, so it is safe to cache forever.

Dither your own image

const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height);
const ink = new Float32Array(width * height);

for (let i = 0; i < ink.length; i++) {
  const [r, g, b] = [data[i * 4], data[i * 4 + 1], data[i * 4 + 2]];
  ink[i] = 1 - (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // brightness to ink
}

const pixels = dither(ink, width, height, "floyd-steinberg");
ctx.putImageData(new ImageData(toRGBA(pixels), width, height), 0, 0);

Size and speed

Scenes are drawn in proportional coordinates, so any size and aspect ratio works. Lines, outlines and stars are kept at least one pixel wide, so small pictures stay readable; from about 240 px across, each scene shows its full detail.

SizeTime to draw
400 × 20010–35 ms
1920 × 1080110–420 ms

Drawing is synchronous, so for large or crisp retro pictures, draw small and scale up without smoothing. It is faster and it looks better.

// 1280 × 720 on screen, 320 × 180 actual pixels
const svg = toSVG(ditherPixels(320, 180, seed), 320, 180, { scale: 4 });

For many pictures at once, render them in a Web Worker, or once on the server, and cache the result.

Stability

Pictures are part of the API. Within a major version, a seed, size, scene and method always draw exactly the same pixels. The test suite pins a hash of every scene and method, and CI checks them on Node 18–22, Bun and Deno. Any change to how pictures look is a major release.

Leaving scene out lets the seed choose from all scenes, so adding a scene in a later major version can change which scene a seed gets. Pass scene explicitly if a picture must never change across major versions.