Skip to content

Components / Backgrounds

Portrait Art

A drawn portrait for a slot that has no photograph yet: a speaker, an artist, a host, a member of a team. A flat bust with no face, seeded from a name so the same name always draws the same person and a list of names draws a varied one: eight kinds of hair, five skin tones, six hair colours, a collar, and now and then glasses, headphones or an earring. The top and the backdrop take the two drawing colours of the colour block it sits on. With tile it fills its box. A link around it tilts the head a little on hover. Zero JavaScript.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import PortraitArt from '@moonarc/core/PortraitArt';
import '@moonarc/core/blocks.css';
---
<a href="/speakers/" class="block aspect-[4/5] overflow-hidden rounded-3xl">
  <PortraitArt seed="Type designer" tile block={2} />
</a>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/portrait-art.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

PortraitArt.astro
---
/**
 * PortraitArt — a drawn portrait for a slot that has no photograph yet: a speaker, an artist, a host, a member of a
 * team. A flat bust with no face (a head, hair, a neck, shoulders in a top with a collar), seeded from a name so the
 * same name always draws the same person and a lineup of names draws a varied one: eight kinds of hair, five skin
 * tones, six hair colours, a collar, and now and then glasses, headphones or an earring. The top and the backdrop take
 * the colours of the colour block it sits on. With `tile` it fills its box. A link around it tilts the head a little on
 * hover. Zero JavaScript.
 *
 * The drawing is no one: it stands in for a photograph you have the right to show, and says so in ASSETS policy terms
 * by never being one.
 */
import type { HTMLAttributes } from 'astro/types';

type Hair = 'short' | 'bob' | 'long' | 'bun' | 'curly' | 'buzz' | 'side' | 'wave';
type Extra = 'none' | 'glasses' | 'headphones' | 'earring';
interface Props extends HTMLAttributes<'div'> {
  /** The name the portrait is drawn from: the same name, the same person on every build. */
  seed: string;
  /** Fill the box: the block's ground, the bust standing at its foot. */
  tile?: boolean;
  /** The block the tile paints (1–5, `blocks.css`); unset, the tile takes the ground it sits on. */
  block?: 1 | 2 | 3 | 4 | 5;
  /** Override the seeded hair. */
  hair?: Hair;
  /** Override the seeded extra. */
  extra?: Extra;
  /** What the picture shows, for a screen reader. Unset, the drawing is decoration and hidden from it. */
  label?: string;
  /** No movement on hover. */
  still?: boolean;
}

const { seed, tile = false, block, hair: hairProp, extra: extraProp, label, still = false, class: className, ...rest } = Astro.props;
// a hash per choice, so the choices do not move together (two names with the same hair can differ in all else)
const hash = (salt: string) => {
  let h = 2166136261;
  for (const ch of salt + seed) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
  return h;
};
const hairs: Hair[] = ['short', 'bob', 'long', 'bun', 'curly', 'buzz', 'side', 'wave'];
const extras: Extra[] = ['none', 'none', 'none', 'glasses', 'glasses', 'headphones', 'earring'];
const skins = ['#f3d5c3', '#e6b796', '#c98f6a', '#93603f', '#5e3d2b'];
const hairColours = ['#1f1714', '#3a2a20', '#6a4630', '#8b4a2c', '#c49455', '#8f8a86'];
const hair = hairProp ?? hairs[hash('hair') % hairs.length]!;
const extra = extraProp ?? extras[hash('extra') % extras.length]!;
const skin = skins[hash('skin') % skins.length]!;
const hairColour = hairColours[hash('colour') % hairColours.length]!;
const vneck = hash('collar') % 2 === 0;
const ring = hash('ring') % 3 === 0;
// which of the block's two drawing colours the top takes (the backdrop takes the other)
const cloth = hash('cloth') % 2 === 0 ? 1 : 2;
const a11y = label ? { role: 'img' as const, 'aria-label': label } : { 'aria-hidden': 'true' as const };
---

<div class:list={['ma-portrait', tile && 'ma-portrait--tile', className]} data-ma-block={block} data-hair={hair} data-cloth={cloth} data-still={still ? '' : undefined} style={`--_skin:${skin};--_hair:${hairColour}`} {...rest}>
  <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMax meet" class="ma-portrait__svg" focusable="false" {...a11y}>
    {ring ? <circle class="ma-portrait__back" cx="50" cy="46" r="34" /> : <path class="ma-portrait__back" d="M18 100V44a32 32 0 0 1 64 0v56Z" />}
    <g class="ma-portrait__body">
      {hair === 'long' && <path class="ma-portrait__hair" d="M30 44C30 22 70 22 70 44L72 80Q50 86 28 80Z" />}
      {hair === 'wave' && <path class="ma-portrait__hair" d="M31 42C31 20 69 20 69 42Q74 52 68 60Q73 68 66 74L34 74Q27 68 32 60Q26 52 31 42Z" />}
      <path class="ma-portrait__cloth" d="M14 101C14 80 30 70 50 70C70 70 86 80 86 101Z" />
      <path class="ma-portrait__cloth-shade" d="M68 74Q86 80 86 101L72 101Q74 84 68 74Z" />
      {vneck ? <path class="ma-portrait__collar" d="M41 70L50 84L59 70Q50 73 41 70Z" /> : <path class="ma-portrait__collar" d="M40 71Q50 78 60 71L58 69Q50 74 42 69Z" />}
      <rect class="ma-portrait__neck" x="43.5" y="58" width="13" height="14" rx="5" />
      <g class="ma-portrait__head">
        <ellipse class="ma-portrait__skin" cx="50" cy="42" rx="16.5" ry="19.5" />
        <ellipse class="ma-portrait__ear" cx="33.6" cy="44" rx="2.6" ry="4" />
        <ellipse class="ma-portrait__ear" cx="66.4" cy="44" rx="2.6" ry="4" />
        {hair === 'short' && <path class="ma-portrait__hair" d="M33 40C32 26 41 21 50 21C61 21 69 27 67 40Q60 31 50 31Q40 31 33 40Z" />}
        {hair === 'bob' && <path class="ma-portrait__hair" d="M32 52C29 30 39 21 50 21C61 21 71 30 68 52L64 52Q66 36 58 31Q48 35 36 33Q34 42 36 52Z" />}
        {hair === 'long' && <path class="ma-portrait__hair" d="M33 42C33 27 41 21 50 21C59 21 67 27 67 42Q58 30 46 31Q40 33 33 42Z" />}
        {hair === 'bun' && (
          <>
            <circle class="ma-portrait__hair" cx="50" cy="17" r="7" />
            <path class="ma-portrait__hair" d="M33 40C33 27 41 22 50 22C59 22 67 27 67 40Q58 30 50 30Q42 30 33 40Z" />
          </>
        )}
        {hair === 'curly' && (
          <g class="ma-portrait__hair">
            <circle cx="38" cy="29" r="7" /><circle cx="47" cy="24" r="7.5" /><circle cx="57" cy="25" r="7" /><circle cx="64" cy="32" r="6" /><circle cx="35" cy="37" r="5" /><circle cx="66" cy="39" r="4.5" />
          </g>
        )}
        {hair === 'buzz' && <path class="ma-portrait__hair ma-portrait__hair--thin" d="M34 38C34 27 41 22.5 50 22.5C59 22.5 66 27 66 38Q58 32 50 32Q42 32 34 38Z" />}
        {hair === 'side' && <path class="ma-portrait__hair" d="M33 44C30 27 40 21 51 21C62 21 70 28 67 42Q62 30 46 30Q40 36 33 44Z" />}
        {hair === 'wave' && <path class="ma-portrait__hair" d="M33 40C33 26 41 21 50 21C60 21 67 27 67 40Q63 33 56 34Q52 28 46 33Q38 31 33 40Z" />}
        {extra === 'glasses' && (
          <g class="ma-portrait__glasses">
            <circle cx="43" cy="45" r="5.2" /><circle cx="57" cy="45" r="5.2" /><path d="M48.2 45h3.6M37.8 44l-4 -1.5M62.2 44l4 -1.5" />
          </g>
        )}
        {extra === 'earring' && <circle class="ma-portrait__ring" cx="33.6" cy="50" r="1.8" />}
      </g>
      {
        extra === 'headphones' && (
          <g class="ma-portrait__phones">
            <path d="M31 46C31 22 69 22 69 46" />
            <rect x="27" y="41" width="7" height="12" rx="3" /><rect x="66" y="41" width="7" height="12" rx="3" />
          </g>
        )
      }
    </g>
  </svg>
</div>

<style is:global>
  @layer components {
    /* the top and the backdrop are the block's two drawing colours; the skin and the hair come from the seed */
    :where(.ma-portrait) { display: grid; place-items: end center; color: var(--ma-block-text, currentColor); }
    :where(.ma-portrait__svg) { width: 100%; height: 100%; overflow: visible; }
    :where(.ma-portrait--tile) { position: relative; overflow: hidden; width: 100%; height: 100%; background: var(--ma-block-ground, transparent); }
    /* placed rather than laid out: a grid item's percentage height resolves against its own content-sized row, so in a
       wide box the bust grew past the foot and the shoulders were cut off. Here it is the smaller of the box's two
       sides at 74 %, standing on the foot */
    :where(.ma-portrait--tile .ma-portrait__svg) { position: absolute; inset-inline-start: 13%; inset-block-end: 0; width: 74%; height: 74%; }
    :where(.ma-portrait) { --_top: var(--ma-motif-1, currentColor); --_backdrop: var(--ma-motif-2, color-mix(in oklab, currentColor 15%, transparent)); }
    :where(.ma-portrait[data-cloth='2']) { --_top: var(--ma-motif-2, currentColor); --_backdrop: var(--ma-motif-1, color-mix(in oklab, currentColor 15%, transparent)); }
    :where(.ma-portrait__back) { fill: var(--_backdrop); opacity: 0.5; }
    :where(.ma-portrait__cloth) { fill: var(--_top); }
    :where(.ma-portrait__cloth-shade) { fill: color-mix(in oklab, var(--_top) 70%, #000); opacity: 0.35; }
    :where(.ma-portrait__collar) { fill: color-mix(in oklab, var(--_top) 35%, #fff); }
    :where(.ma-portrait__skin, .ma-portrait__ear) { fill: var(--_skin); }
    :where(.ma-portrait__neck) { fill: color-mix(in oklab, var(--_skin) 82%, #000); }
    :where(.ma-portrait__hair) { fill: var(--_hair); }
    :where(.ma-portrait__glasses) { fill: none; stroke: var(--ma-block-text, #1a1a1a); stroke-width: 1.6; }
    :where(.ma-portrait__phones) { fill: var(--ma-block-text, #1a1a1a); stroke: var(--ma-block-text, #1a1a1a); stroke-width: 3; }
    :where(.ma-portrait__phones path) { fill: none; }
    :where(.ma-portrait__ring) { fill: var(--ma-motif-2, #e8c46a); }
    /* a link around it tilts the head a little, and the shoulders follow */
    :where(.ma-portrait svg :is(.ma-portrait__head, .ma-portrait__body)) { transform-box: view-box; transition: rotate var(--ma-duration-slow, 700ms) var(--ma-ease-out), translate var(--ma-duration-slow, 700ms) var(--ma-ease-out); }
    :where(.ma-portrait__head) { transform-origin: 50px 66px; }
    :where(.ma-portrait__body) { transform-origin: 50px 100px; }
    @media (hover: hover) and (pointer: fine) {
      :where(:is(:is(a, button, .ma-portrait-host):is(:hover, :focus-visible), .ma-portrait-host:has(:focus-visible)) .ma-portrait:not([data-still]) .ma-portrait__head) { rotate: -6deg; }
      :where(:is(:is(a, button, .ma-portrait-host):is(:hover, :focus-visible), .ma-portrait-host:has(:focus-visible)) .ma-portrait:not([data-still]) .ma-portrait__body) { translate: 0 calc(var(--ma-travel-hover, 4px) * -0.6); }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-portrait svg *) { transition: none; }
    }
  }
</style>

Props

PropTypeDefaultDescription
seedstringnoneThe name the portrait is drawn from: the same name, the same person on every build.
tilebooleanfalseFill the box: the block colour, the bust standing at its foot.
block1 | 2 | 3 | 4 | 5noneThe colour block the tile paints, from @moonarc/core/blocks.css or a palette. Unset, the tile takes the ground it sits on.
hair'short' | 'bob' | 'long' | 'bun' | 'curly' | 'buzz' | 'side' | 'wave'noneOverride the seeded hair.
extra'none' | 'glasses' | 'headphones' | 'earring'noneOverride the seeded extra.
labelstringnoneWhat the picture shows, for a screen reader (role="img"). Unset, the drawing is decoration and hidden from it.
stillbooleanfalseNo movement on hover.

Reduced motion

Nothing moves: the head stays upright.

With ClientRouter

CSS and SVG only; nothing to rebind.

Why it is built this way

Replaces: Avatar generators (Boring Avatars, DiceBear) · Stock headshots. See the migration table.

A row of overlapping avatars with a count chip such as "+2 398".

every browserhover

Motif

Backgrounds

A drawn picture for a slot that has no photograph yet: a sun over the sea, a split gate, a flower, a grid of tiles, a phone going live, a plate set for dinner, waves under a moon or three shapes.

every browserhover

Product Art

Backgrounds

A product drawn flat, for a shop slot that has no photograph yet: 29 drawings in six sets (clothes, ceramics, coffee, skincare, furniture and objects, bakery).

every browserhover

Who is on: each person a drawn portrait on a colour block (PortraitArt, seeded from the name), the name and what they do, and on the back of the portrait their session: its title, the day and time, the stage.

newly · Chrome 105 · Firefox 121 · Safari 15.4hover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown