# Portrait Art (Moonarc)

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.

- Import: `import PortraitArt from '@moonarc/core/PortraitArt'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/portrait-art.json`
- Tier A · category background · trigger hover
- Readout: `<PortraitArt seed="Type designer" tile block={2} />`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 6.3 kB raw)
- Page: https://moonarc.dev/components/portrait-art/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `seed` | `string` | none | The name the portrait is drawn from: the same name, the same person on every build. |
| `tile` | `boolean` | `false` | Fill the box: the block colour, the bust standing at its foot. |
| `block` | `1 | 2 | 3 | 4 | 5` | none | The 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'` | none | Override the seeded hair. |
| `extra` | `'none' | 'glasses' | 'headphones' | 'earring'` | none | Override the seeded extra. |
| `label` | `string` | none | What the picture shows, for a screen reader (role="img"). Unset, the drawing is decoration and hidden from it. |
| `still` | `boolean` | `false` | No movement on hover. |

## Usage

```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>
```

## Reduced motion

Nothing moves: the head stays upright.

## With ClientRouter

CSS and SVG only; nothing to rebind.

## Craft

- Each choice (hair, skin, colour, extra, collar, backdrop) is hashed from the name with its own salt, so two names that share a haircut still differ in everything else.
- There is no face: a flat bust reads as a person at a glance and as nobody in particular, which is what a placeholder for someone else's photograph should be.
- The top and the backdrop swap between the block's two drawing colours by the seed, so a lineup on one palette is not one colour of shirt.

## Replaces

- Avatar generators (Boring Avatars, DiceBear)
- Stock headshots

## Source

```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>

```
