Paper avatars on one canvas

BoardUI has a block called Agent Creator↗. You pick an expression from a ring of sixteen faces orbiting your avatar, drag a shape in from an arc underneath, and choose a colour. Every avatar looks cut out of dyed card: a little grain, a thick edge, a soft shadow.

It's a paid Pro block, so the source isn't public. I opened it in Chrome DevTools to see how it was drawn, and then rebuilt it my own way. This is that rebuild. Drag the shapes, tap a face, pick a colour.

Twenty-six canvases

The first thing DevTools showed me was the canvas count. There are twenty-six <canvas> elements in that preview: one for the big avatar, one for each of the nine shapes, and one for each expression disc. The ones I checked all hand back a webgl2 context, so each avatar is its own little WebGL scene.

That's a clean way to build a component library, because each avatar is self-contained and you can drop one anywhere. It is not the cheapest way to draw one stage. So I went the other way: one 2D canvas for the whole stage, one requestAnimationFrame loop, and every avatar drawn into it in turn.

The buttons are still real buttons. They're transparent, laid over the canvas, and the loop moves them every frame so they stay on top of the faces they belong to. A screen reader sees sixteen expression buttons and a listbox of nine shapes. The canvas is only the picture.

Same shapes as the clay

This sits on the trick from the clay avatars. A silhouette isn't a path. It's a list of distances from the centre to the edge, one per angle. Clay used 64. Paper uses 128, because a star's point or a heart's notch needs the resolution, but it's the same idea. It's also what makes the eye check further down cheap: the edge at any angle is one array lookup.

What changed is the renderer. Here's the same cloud from both engines:

clay · one SVG path
paper · one 2D canvas

The clay one is a single SVG path filled with one radial gradient. The paper one is five passes on a canvas:

  1. Shadow. The silhouette, filled with a soft blur, a little below the card.
  2. Edge. The silhouette restamped a few times along a direction, in a darker tone. That stack is the card's thickness, and the direction follows the tilt, so the edge swings around as the card turns.
  3. Face. The silhouette in the washed-out colour.
  4. Grain. A tile of noise and a few hundred tiny fibre strokes, built once, laid over with multiply so it can only darken. The tile is anchored to the card, so the grain travels with it instead of swimming underneath.
  5. Light. A gradient from the upper left in soft-light, then a thin highlight along the top of the cut edge.

The eyes go on last, in ink.

// the card's thickness: the silhouette restamped along the edge direction
for (let s = steps; s >= 1; s--) {
  const t = s / steps;
  tracePath(ctx, radii, cx + ex * t, cy + ey * t, R, sx, sy, roll);
  ctx.fill();
}

There's no 3D here at all. Turning is the card getting slightly narrower, the edge sliding to the other side, and the face shifting toward the turn. At this size, that's enough.

Nine shapes

  • petal
  • flower
  • star
  • heart
  • cloud
  • diamond
  • shield
  • slender
  • pocket

Each one comes from whatever is easiest to describe. The cloud is five circles, solved per ray. The heart is the classic parametric heart curve, shifted down so the notch stays visible from the centre. The star, diamond and shield are polygons. Then a couple of passes of circular blur over the radii take the edge off every corner, because scissors don't cut a perfect point.

Sixteen faces

A face is two eyes and up to two brows, and an eye is one of four marks: a filled pill, a ^, a ‿, or a –. Angry is short pills with the inner end of each brow pushed down. Sad is the same brows tipped the other way. Thinking is mismatched eyes, one raised brow, and a default gaze up and to the left.

A face change squashes the eyes shut and opens them on the new face, the same asymmetric timing as a blink. Each face also has a gesture, and it only plays when you click. Hovering a face does nothing. The only thing that tracks the pointer is where the avatar looks: the card turns a little toward your cursor anywhere on the page, and the eyes follow it. Angry shakes its head and runs out of steam. Happy hops twice, the second hop smaller. Excited does three quick hops and a full spin. Sleepy droops, sways once, and comes back up slowly. Every gesture is a function of progress that returns to zero at the end, so none of them can leave the card stuck somewhere.

A new shape doesn't morph. The card turns over, hangs edge-on for a moment, and comes back round with the new shape on its back. That's what paper does.

Keeping the eyes on the card

Big eyes on a cloud are fine. The same eyes on a star poke out between the points. So before the face is drawn, every outer corner of every eye and brow is checked against the body's own radius at that corner's angle. The face shrinks until the worst one sits inside 78% of the edge.

for (const p of pts) {
  const limit = radiusAt(radii, Math.atan2(p.y, p.x)) * R * 0.78;
  s = Math.min(s, limit / Math.hypot(p.x, p.y));
}

Uniform scaling doesn't change a point's angle, so each point gives its own limit directly, and there's no search. A cloud keeps the full face, a star gets a smaller one, and the heart's notch is respected.