Dot Matrix

eighty objects, one grid

Eighty everyday objects rasterised at build time onto a fixed 40-cell grid, then morphed between on canvas — where dots only ever move, and never appear or disappear.

role
all of it
timeline
2026
status
open source
stack
React · Vite · TypeScript · Canvas · sharp
01

One cell, one dot, nothing else

The whole project is one rule held to stubbornly. Every object is drawn on a grid forty cells wide. A cell is either ink or it is not. A cell that is ink gets one small rounded square, and there is no second tool — no strokes, no shading, no half-tones, and no exception for the object that would look better with one.

The rule is the point. A bicycle has to survive as a bicycle using only whether each of a few hundred cells is on, which turns a drawing problem into a threshold problem and moves the interesting decisions out of an illustrator's hand and into a build script.

Fig. 01Thirteen of the eighty, with the real cell data. Pick a word — or tab through them — and watch the count in flight change with the pair you chose.
02

Turning a vector into a lattice

Each source SVG is public domain, and each is pinned to a 1200px-wide raster before it goes to sharp, so librsvg draws it crisply at that size rather than scaling a small render up.

Every pixel is then classified as ink or not — by alpha where the artwork sits on a transparent canvas, and by distance from a corner-sampled background colour where it does not. The ink bounding box is trimmed so padding baked into a source file cannot shrink the object inside its grid, and the grid's aspect comes from the object rather than the canvas it arrived on.

Each of the 40 × N cells then takes the mean ink coverage of the pixels underneath it, via a summed-area table, and fills when coverage reaches 0.34.

0.34, not 0.5. A strict majority drops one-pixel features — bicycle spokes, the legs of a chair — and the silhouette stops reading.

03

Scatter, not sweep

The page is one display and eighty word buttons. Click a word, or tab to it, and the dots travel to that shape; click another mid-flight and they redirect from wherever they currently are rather than snapping back to a whole shape first.

The obvious way to pair dots is by index — dot i goes to cell i of the target. That looked wrong, and the reason is in the build step: the vectoriser writes cells out row by row, so pairing by index sends the whole field sweeping in one direction like a wipe.

So each morph reshuffles the target's cells with a Fisher–Yates pass first. Dot i is sent to an arbitrary cell of the shape it is heading for, which means the old silhouette dissolves into a loose cloud and reassembles as the new one. The path itself stays a straight line on a quintic ease-in-out over 900ms. Nothing fades; dots only move.

04

Sizing the transition, not the set

Pairing dot i with dot i needs both shapes to carry the same number of dots, and these do not — they run from 347 for an axe to 3,246 for a bottle.

Padding every shape up to the heaviest one looks like the obvious fix and is the wrong one. A hammer-to-fork transition — 409 dots to 416 — would then drag 3,246 dots through a field that is over 80% full, and a random reassignment in a space that crowded has nowhere empty to scatter into. It stops reading as a scatter and starts reading as fog.

Instead each transition sizes itself to max(source, target), padding only the lighter of the two and collapsing back to the target's real count once it lands. A light pair now moves through roughly 6% of the field instead of 80%. A genuinely heavy target still gets all the density it needs — but only when it is actually the one involved.

Constants

canvas maximum600 × 600px / 300 × 300px on mobile
dot size6 × 6px / 3 × 3px
corner radius2.4px / 1.2px
grid resolution40 cells wide, height follows the source aspect
morph900ms, quintic ease-in-out, straight-line paths
roster80 shapes, 347 to 3,246 dots
05

Making it cheap

Canvas rather than SVG, because repositioning thousands of elements a frame is not something the DOM should be asked to do.

Every dot is the same rounded square, so one sprite is pre-rendered at device resolution and then stamped. Scratch buffers are sized once to the heaviest icon in the set, so no transition allocates — only the first activeLen entries of each are meaningful at any moment. Device pixel ratio is capped at 2, past which the extra backing pixels cost fill rate and buy nothing a dot this small can show.

Under prefers-reduced-motion: reduce the display jumps straight to the picked shape. Nothing is fetched or rasterised at runtime: the generated registry is a static array of cell coordinates, and the grid is the only source of truth the renderer has.

Provenance

  • Sixty-five borrowedFrom CC0 icon sets — Pinhead by Quincy Morgan, Temaki by Bryan Housel, and Mapbox's Maki. Every asset carries its source URL and exact licence string in an XML comment at the top of the file.
  • Fifteen drawnBackpack, broom, drum, guitar, headphones, magnet, mirror, notebook, pencil, plate, refrigerator, rope, sword, toaster and umbrella are original artwork for this repository, dedicated to the public domain under CC0. No CC0 silhouette of those objects existed in the libraries reachable from where it was built.
  • Adding one is three stepsDrop a CC0 vector in, run the vectoriser, and the picker grows a button. If the result does not read as the named object at 40 cells wide, the fix is a better source vector — never a tuned renderer.