Avatars from a Username

By Pablo Fernandez4 min read

The last post was about turning one of Vasarely's paintings into a function. This one keeps going in the same direction, but with a smaller and more practical target: avatars.

It started by accident. I was clicking around Vercel's dashboard and noticed the avatar they'd assigned me: a dithered gradient of tiny squares, generated straight from my username, exactly the kind of thing this post is about to describe. Default avatars are usually an afterthought, a grey silhouette or a colored circle with your initial in it, so it was nice to see someone treat that forgotten corner of a product as something worth designing. I wanted to see if I could rebuild it.

The idea is the same as the Vasarely engine (a pure function, seeded by a string, rendered to SVG), just pointed at something you'd actually put next to a comment. You hand the generator a username and it gives you back a picture that's stable for that name. Same name, same avatar, forever.

avatar for ana
ana
avatar for ben
ben
avatar for cleo
cleo
avatar for dan
dan
avatar for pablofr
pablofr

Every one of those is a pure function of the username. There's no database, no upload, no Math.random. If two people pick the same handle they get the same avatar, and if you delete yours and come back later it's exactly the one you had.

The trick is ordered dithering, not noise#

The look here is Vercel's: a solid corner that dissolves into a clean stipple and then into empty background. The obvious way to fake that transition is to scatter random dots, but random dots read as TV static, with cells clumping together and leaving holes. What you actually want is ordered dithering, where a small repeating matrix decides which cells turn on so that equal-valued cells stay as far apart as possible. That's what gives you the tidy checkerboard mesh instead of noise.

The matrix is an 8×8 Bayer table. For each cell you compare the local gradient intensity against the matrix value at that position:

TSavatar.ts
TypeScript
const BAYER8 = [
  [0, 32, 8, 40, 2, 34, 10, 42],
  [48, 16, 56, 24, 50, 18, 58, 26],
  // …8×8, values 0..63, arranged to spread equal thresholds apart
];
 
for (let y = 0; y < GRID; y++) {
  for (let x = 0; x < GRID; x++) {
    const proj = (x - c) * dx + (y - c) * dy;        // position along the axis
    const intensity = clamp01(1.15 - 1.3 * (proj / half + 1) / 2);
    const threshold = (BAYER8[y % 8][x % 8] + 0.5) / 64;
    row.push(intensity > threshold);                 // this module is on/off
  }
}

The gradient axis is rotated by a seeded angle, so the dense corner lands in a different place for every username. Push the intensity past the [0, 1] range and clamp it, and you get a solid patch at one end, a clean patch at the other, and a wide dithered band in the middle. An 8×8 matrix (rather than 4×4) matters more than it looks: at a coarse size the mesh tiles into a visible comb whenever the axis lands near horizontal or vertical, and 8×8 holds up across the whole circle of angles.

gradient intensity Bayer threshold(0 = empty, 1 = solid) BAYER8[y%8][x%8] / 64 │ │ └───────────────┬───────────────┘ intensity > threshold ? ┌─────────────┴─────────────┐ ▼ ▼ module ON module OFF (draw the dot) (stay background)
How one cell turns on or off: its gradient intensity is compared against the Bayer threshold for that position.

Colors come from the name too#

The pattern is only half of it. The two colors (a deep background and a brighter foreground) are drawn from the same seeded stream as the grid, so the whole avatar is one pure function of the username:

TSavatar.ts
TypeScript
export function renderAvatarSvg(username: string, size: number): string {
  const rand = seededRng(username);        // one PRNG stream, seeded by the name
  const { bg, fg } = colorPair(rand);      // a legible background/foreground pair
  const grid = buildGrid(rand);            // dithered module grid, same stream
  // …emit the grid as one crisp <path> of horizontal runs
}

Because the color and the pattern come off the same stream, there's nothing to keep in sync. The name is the only input.

"pablofr" seededRng(name) one PRNG stream, seeded once ┌─────┴─────┐ ▼ ▼colorPair buildGrid (bg, fg) (modules) │ │ └─────┬─────┘ renderAvatarSvg ──▶ <svg> (same name, same avatar)
One username seeds a single PRNG stream that feeds both the color pair and the dithered grid before they render to one SVG.

Same idea, softer output: halftone#

Ordered dithering gives you hard pixels. If you want something that reads as print rather than screen, swap the module grid for a halftone screen: a grid of dots where the dot size carries the image instead of on/off cells. Here are the same usernames again, run through the halftone engine:

avatar for ana
ana
avatar for ben
ben
avatar for cleo
cleo
avatar for dan
dan
avatar for pablofr
pablofr

The dots aren't random sizes. Their size follows a smooth field, so neighbouring dots are always close in size and you never get a speck sitting next to a full cell. For the general version I use fractal noise for that field, which makes the big dots gather into soft islands while a few strays speckle the quiet areas:

TShalftone.ts
TypeScript
// dot radius from a smooth fractal-noise field → gradual size changes
const t = fbm(x / warp, y / warp, octaves);       // 0..1, continuous
const radius = cell * (fillMin + (fillMax - fillMin) * t);
 
// each dot is colored on its own: sample the background gradient here,
// then step it a few tones darker in OKLCH (lower L, a touch more chroma)
const dot = oklch(bgHere.L - darkness, bgHere.C + 0.02, bgHere.H);

Coloring each dot individually is what keeps it from looking like a flat pattern stamped on top. A dot over amber comes out deep amber, a dot over green comes out deep green. To keep the SVG small the shades get bucketed into a few dozen tones and grouped, so you're not paying for a unique fill on every dot.

At a larger size the size field is the whole point. This is the same engine with no avatar framing, just the dots doing the drawing:

The halftone field on its own: dot size follows a fractal-noise mesh, each dot shaded from the gradient beneath it.
The halftone field on its own: dot size follows a fractal-noise mesh, each dot shaded from the gradient beneath it.

Try it#

Type a name and pick a style. Dither gives you the crisp identicon, halftone gives you the printed version. Both are the same picture the backend would hand out for that name anywhere else.

avatar for pablofr

Same seed and style always give the same avatar, so anything you make here is reproducible.

Where this goes#

Deterministic avatars are the boring, useful end of the same toolkit as the Vasarely spheres. A username becomes a picture with no storage and no coordination, it survives the account being deleted and recreated, and it caches forever because the output only depends on the input. The interesting part is that dithering and halftoning are just two ways of turning a smooth gradient into discrete marks, and once the gradient is seeded from a string, the marks are yours for good.

Related articles

Giving an Inbox to Each Agent

Password resets, invoices, and support replies still arrive by email, and my agents live on HTTP. Mailingest is the Rust mail platform I built so that an inbox costs one API call: an address whose mail shows up as a signed webhook.

Building a Better WhatsApp SDK

whatsapp-web.js runs a whole headless Chromium to act like a phone. The other door skips the browser and speaks WhatsApp's native protocol over a plain WebSocket, then hands you a raw socket and a firehose of events. whatsweb is the thin layer I built to make that socket feel like a bot.

The Distance From a Message to Your Code

The same bot, a user's message answered by code I wrote, costs a handful of lines on Telegram, a gateway and a bag of intents on Discord, and a whole headless browser pretending to be a phone on WhatsApp. Four libraries in, the pattern was clear, and the hard part was never the bot.