A face made of dots

This is me, as 8,800 dots. Each dot is a pixel of a small black-and-white photo, and its size is how bright that pixel was. Up close it is just a field of circles. Lean back and there is a face.

Move over the portrait. Click to send a ripple.

It lived for a short while at /dot-portrait, a page with no links pointing to it, while I tried out ideas for the portrait in the contact section of my site. The photo won (it is black and white and comes into focus as you scroll) and the dots went into a backup branch. This is them coming back out.

The data

The portrait is a grid of 100 by 88 brightness values, one byte each, from 0 to 255. That is 8,800 bytes, stored in the page as a base64 string and decoded once with atob. The edges are already faded to black in the data, so the face sits in the dark without a hard border. Dots darker than 4% are skipped entirely, which is most of the background.

Each frame draws every visible dot into one path and fills it once. Thousands of tiny arc calls are cheap. Thousands of tiny fill calls are not.

Four motions

Everything that moves is a small multiplier on one number: the dot's radius. Nothing is stored per dot except its brightness.

  1. The intro. Dots grow in from the face outwards, starting at a point 47% across and 43% down. Each dot waits in proportion to its distance from there, then grows with a back-out ease that overshoots by a touch and settles. The whole thing takes 2.2 seconds.
  2. Breathing. Every dot swells and shrinks by 7% on a slow sine wave whose phase depends on its column and row, so the swell rolls diagonally across the face instead of pulsing all at once. It is almost too small to notice, which is the point.
  3. A passing light. Every 7 seconds a soft diagonal band sweeps across the portrait and lifts each dot it touches by up to 35%. It reads as light catching the face, though it is only a Gaussian bump moving along x + y/2.
  4. The lens and the ripple. Inside 120px of the pointer, dots are pushed outwards by up to 0.7 of a cell and grow by up to 80%, both on a squared falloff. A click or tap sends out a ring that travels at 260px a second and fades over 1.6 seconds.

The dots are capped at 62% of a cell, so even at their biggest the neighbours never quite touch. Without the cap, the lens turns the cheek into a white blob.

var CFG = {
  intro: 2.2,        // seconds for the dots to grow in, starting from the face
  breathe: 0.07,     // how much each dot swells and shrinks while idle
  sweep: 0.35,       // brightness of the light band that crosses the portrait
  sweepEvery: 7,     // seconds between sweeps
  lensRadius: 120,   // pointer influence in CSS pixels
  lensPush: 0.7,     // how far dots are pushed away (in dot widths)
  lensGrow: 0.8,     // how much dots swell near the pointer
  ripple: 0.9        // size of the ring sent out by a click or tap
};

Tune it

The original page kept all of this in a settings object at the top so I could tweak it without reading the drawing code. Here are four of them as sliders, wired to the portrait above.

Two small changes from the original, both for this page. The clock starts when the portrait first scrolls into view rather than when the page loads, so you actually see the intro, and there is a button to play it again. With reduced motion on, you get the finished portrait as a still and nothing moves.

Built
October 2026
Stack
Canvas 2D, one path per frame
Lived at
thomasalex.in/dot-portrait (unlinked, now retired)
Source
dot-portrait.html on the backup branch

Built with Claude Code. The face is mine; the dots are on loan from a photo.