How the ASCII portrait on this site works
A small canvas drawing that turns a photo into characters and back: a tone ramp, a wave that opens from the eyes and a develop that leaves no squares.
The portrait on the home page of this site is not a photo, it is a <canvas>. When the page opens, my face is written out in characters starting from the eyes, stays as ASCII for a moment, then slowly turns into the photo. Move the pointer over it and the photo underneath turns back into code. No library; about 900 lines of TypeScript.
This note is how it works. The idea is simple, but it took a few small tricks to make it look right.
Cutting the photo into cells
First I removed the photo's background, so the portrait is a transparent WebP. The canvas is split into cells 4.75 pixels wide. A character is about 1.7 times taller than it is wide, so the cells are too. Each cell samples the photo at 4×4 points and works out three things:
- Lightness: how dark the cell is
- Edge: a Sobel filter, to see whether a strong line runs through it
- Subject: from the transparency, whether the cell is part of the portrait
Lightness becomes a character through a tone ramp:
const RAMP = " .-:;*+x#%@";
On a light page dark pixels get the dense characters (@, %); on a dark page it is the other way round. Switching the theme rebuilds the ramp.
Making the face readable
In the first attempt you could not recognise the face. Hair and jacket are dark, so they took all the dense characters and the face was left almost empty. The fix was to treat the face on its own. I defined an ellipse around the head; the cells inside it are ranked against each other and spread evenly over the ramp. That way eyes, brows, glasses and mouth use the whole ramp whatever the lighting. Outside the ellipse edges get a little extra weight, so outlines do not disappear.
Drawing it fast
Drawing every character with its own fillText is slow. Instead each row is drawn as one string. Because the tone bands have different opacities, each row is split into six "layers" (three tone bands, the lens, the decode front and the faint dots), each a line of spaces apart from its own characters. That is at most six calls per row.
One small trap: Geist Mono has programming ligatures, so == or <> next to each other become a single sign and the grid drifts. Setting textRendering = "optimizeSpeed" and an almost-zero letterSpacing each turn ligatures off.
A wave that opens from the eyes
Every cell has a delay: its distance from the eyes, plus a little randomness. Just before the wave reaches a cell, a faint dot appears there. When it arrives the cell shows a random character for a moment, then its own character at full ink, which settles to its tone over a quarter of a second. That is why the face looks like it is being written.
This runs on the same clock as the headline. I read the start time of the CSS animation:
const animation = document.querySelector("[data-hero-clock]")?.getAnimations()[0];
await animation?.ready;
const t0 = animation?.startTime; // same timebase as requestAnimationFrame
If the canvas is ready late on a slow phone, the intro is not skipped; it plays from the start, a little faster.
A develop without squares
The first version of the change from ASCII to photo was bad. I uncovered each cell's photo in four steps, and the moment in between looked like a censor mosaic. Now every frame draws a tiny mask with one pixel per cell, whose opacity is how far that cell has turned into the photo. The mask is scaled up to the canvas with smoothing and combined with the photo:
ctx.drawImage(photo, 0, 0, width, height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(mask, 0, 0, width, height); // an 84×50 mask, softened as it is scaled up
ctx.globalCompositeOperation = "source-over";
The smoothing during the scale-up turns hard cell edges into a soft front for free. Meanwhile the characters thin down the ramp (@ → # → + → : → .), so for a moment code and photo sit on top of each other like a double exposure.
The lens, and stopping
While the pointer is over the portrait, the cells under it "heat up", and the heat fades over 400 milliseconds. A hot cell pulls the photo back through the same mask and shows code characters instead. On fast mouse moves I fill in the points between samples, so the trail does not break. On a phone, a tap sends out a single ring of code.
My favourite part may be the most boring one: once everything is photo and the pointer is still, the drawing stops completely. Not a single frame is drawn while idle. It also stops when the tab is in the background or the portrait is off screen. With "reduce motion" turned on, the portrait is a still photo from the start.
The "ascii ─●─ photo" switch under it is fed by the same drawing: the dot shows how much of the portrait is photo. Click it and the whole portrait turns back into code, in a wave that starts at the eyes.