# Motif (Moonarc)

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. Each is a few flat SVG shapes in two colours read from the colour block or tone it sits on, so one drawing works on every ground; with tile it fills its box with the block colour, a soft light and an optional caption. Parts move a little while a link around it is hovered or focused. Zero JavaScript.

- Import: `import Motif from '@moonarc/core/Motif'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/motif.json`
- Tier A · category background · trigger hover
- Readout: `<Motif name="sun" tile block={2} caption="Menu design" />`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 9.2 kB raw)
- Page: https://moonarc.dev/components/motif/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `name` | `'sun' | 'gate' | 'flower' | 'grid' | 'phone' | 'plate' | 'wave' | 'shapes'` | none | The drawing. Unset, seed picks one. |
| `seed` | `string` | `''` | A word the drawing is picked from when name is unset, the same drawing for the same word on every build: a project title, a label. |
| `tile` | `boolean` | `false` | Fill the box: the block colour, the drawing at three quarters of the box, a soft light from the top left. Without it the drawing alone fills its box. |
| `block` | `1 | 2 | 3 | 4 | 5` | none | The colour block the tile paints, from @moonarc/core/blocks.css or a palette. Unset, the drawing takes the block or tone it already sits in. |
| `caption` | `string` | none | A short caption in a pill at the tile's lower left (tile only). |
| `label` | `string` | none | What the picture shows, for a screen reader (role="img"). Unset, the drawing is decoration and hidden from it. |
| `badge` | `string` | `'LIVE'` | The word on the phone's badge. |
| `still` | `boolean` | `false` | No movement on hover. |

## Usage

```astro
---
import Motif from '@moonarc/core/Motif';
import '@moonarc/core/blocks.css';
---
<a href="/work/one/" class="block aspect-[4/3]">
  <Motif name="plate" tile block={3} caption="Menu design" />
</a>
```

## Reduced motion

Nothing moves: the parts stay where they are drawn and the phone's hearts are shown at rest.

## With ClientRouter

CSS and SVG only; nothing to rebind.

## Craft

- Two colours and the ground: --ma-motif-1 and --ma-motif-2 come from the block (a palette picks them from the rest of its colours by contrast), the cut-outs take the ground and the dark accents the text, so the same drawing is right on coral, lagoon or butter.
- The movement is a few view-box units on the hover travel token, on the slow duration: a drawing that moves more than its link does pulls the eye off the words.
- A link, a button or any element marked ma-motif-host sets it off, on hover and on keyboard focus alike; touch screens get the still drawing.
- The hearts on the phone rise three times while it is hovered, then rest. A loop that runs on its own next to text is the thing WCAG 2.2.2 asks a control for.
- The seed is a hash of the word, so a project keeps its drawing between builds and two projects rarely share one.

## Replaces

- Placeholder images (Framer templates)
- Illustration packs (Humaaans, Open Peeps)

## Source

```astro
---
/**
 * Motif — 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, three shapes. Each is a few flat SVG shapes
 * in two colours read from the block or tone it sits on (--ma-motif-1, --ma-motif-2, with the ground and the text for
 * the cut-outs), so one drawing works on every ground. With `tile` it fills its box: the block's colour, the drawing in
 * the middle, a soft light across it, and an optional label in a pill. Parts move a little while a link around it is
 * hovered or focused. Zero JavaScript.
 *
 * `seed` picks a drawing from a word when `name` is not given, the same one on every build (a project's title, say).
 */
import type { HTMLAttributes } from 'astro/types';

const motifs = ['sun', 'gate', 'flower', 'grid', 'phone', 'plate', 'wave', 'shapes'] as const;
type MotifName = (typeof motifs)[number];

interface Props extends HTMLAttributes<'div'> {
  /** The drawing. Unset, `seed` picks one. */
  name?: MotifName;
  /** A word the drawing is picked from when `name` is unset: the same word, the same drawing. */
  seed?: string;
  /** Fill the box: the block's ground, the drawing centred, a soft light. */
  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;
  /** A short caption in a pill at the tile's lower edge. */
  caption?: string;
  /** What the picture shows, for a screen reader. Unset, the drawing is decoration and hidden from it. */
  label?: string;
  /** The word on the phone's badge. */
  badge?: string;
  /** No movement on hover. */
  still?: boolean;
}

const { name, seed = '', tile = false, block, caption, label, badge = 'LIVE', still = false, class: className, ...rest } = Astro.props;
let hash = 0;
for (const ch of seed) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
const motif: MotifName = name ?? motifs[hash % motifs.length]!;

const petals = [0, 72, 144, 216, 288];
const cells: [number, number, 'f1' | 'f2' | 's1', 'up' | 'down'][] = [
  [14.5, 14.5, 'f1', 'up'], [39.5, 14.5, 's1', 'down'], [64.5, 14.5, 'f2', 'up'],
  [14.5, 39.5, 'f2', 'down'], [39.5, 39.5, 'f1', 'up'], [64.5, 39.5, 's1', 'down'],
  [14.5, 64.5, 's1', 'up'], [39.5, 64.5, 'f2', 'down'], [64.5, 64.5, 'f1', 'up'],
];
const heart = 'M0 -1.6C0 -4.2 -3.6 -4.6 -3.6 -1.4C-3.6 1 0 3.1 0 4.2C0 3.1 3.6 1 3.6 -1.4C3.6 -4.6 0 -4.2 0 -1.6Z';
const wave = (y: number) => `M-12 ${y}q8 -6 16 0t16 0t16 0t16 0t16 0t16 0t16 0t16 0`;
const a11y = label ? { role: 'img' as const, 'aria-label': label } : { 'aria-hidden': 'true' as const };
---

<div class:list={['ma-motif', tile && 'ma-motif--tile', className]} data-ma-block={block} data-motif={motif} data-still={still ? '' : undefined} {...rest}>
  <svg viewBox="0 0 100 100" class="ma-motif__svg" focusable="false" {...a11y}>
    {
      motif === 'sun' && (
        <>
          <g class="ma-motif__rise"><circle class="ma-motif__f1" cx="50" cy="50" r="28" /></g>
          <g class="ma-motif__fb">
            <rect x="14" y="55" width="72" height="2.4" />
            <rect x="14" y="61" width="72" height="3.4" />
            <rect x="14" y="68" width="72" height="4.4" />
            <rect x="14" y="76" width="72" height="8" />
          </g>
          <rect class="ma-motif__f2" x="18" y="85" width="64" height="2" rx="1" />
        </>
      )
    }
    {
      motif === 'gate' && (
        <>
          <g class="ma-motif__rise"><circle class="ma-motif__f2" cx="50" cy="44" r="15" /></g>
          <g class="ma-motif__left"><polygon class="ma-motif__f1" points="14,88 14,78 18,78 18,68 22,68 22,58 26,58 26,48 30,48 30,38 34,38 34,29 38,29 38,21 42,21 46,9 47,9 47,88" /></g>
          <g class="ma-motif__right"><polygon class="ma-motif__f1" points="86,88 86,78 82,78 82,68 78,68 78,58 74,58 74,48 70,48 70,38 66,38 66,29 62,29 62,21 58,21 54,9 53,9 53,88" /></g>
          <rect class="ma-motif__f1" x="8" y="88" width="84" height="2.2" rx="1.1" />
        </>
      )
    }
    {
      motif === 'flower' && (
        <g class="ma-motif__spin">
          {petals.map((a) => <path class="ma-motif__f1" d="M50 50C43 45 39 31 45 21C49 14 58 15 61 23C64 33 57 45 50 50Z" transform={`rotate(${a} 50 50)`} />)}
          <circle class="ma-motif__f2" cx="50" cy="50" r="6.5" />
        </g>
      )
    }
    {
      motif === 'grid' &&
        cells.map(([x, y, fill, move]) =>
          fill === 's1' ? (
            <rect class={`ma-motif__s1 ma-motif__${move}`} x={x + 0.9} y={y + 0.9} width="19.2" height="19.2" rx="4" stroke-width="1.8" />
          ) : (
            <rect class={`ma-motif__${fill} ma-motif__${move}`} x={x} y={y} width="21" height="21" rx="4.5" />
          ),
        )
    }
    {
      motif === 'phone' && (
        <>
          <rect class="ma-motif__f1" x="29" y="7" width="42" height="86" rx="9" />
          <rect class="ma-motif__fb" x="32.5" y="11" width="35" height="78" rx="6" opacity="0.42" />
          <rect class="ma-motif__pill" x="36" y="16" width="15" height="6.4" rx="3.2" />
          <text class="ma-motif__badge" x="43.5" y="20.5" text-anchor="middle">{badge}</text>
          <rect class="ma-motif__f1" x="36" y="80" width="21" height="4" rx="2" opacity="0.7" />
          <g class="ma-motif__f2">
            <g transform="translate(60 76)"><path class="ma-motif__heart" d={heart} style="--ma-motif-d:0s;--ma-motif-hx:calc(var(--ma-travel-hover, 4px) * -0.75)" /></g>
            <g transform="translate(56 78) scale(0.75)"><path class="ma-motif__heart" d={heart} style="--ma-motif-d:calc(var(--ma-stagger, 60ms) * 5);--ma-motif-hx:var(--ma-travel-hover, 4px)" /></g>
            <g transform="translate(62 79) scale(0.6)"><path class="ma-motif__heart" d={heart} style="--ma-motif-d:calc(var(--ma-stagger, 60ms) * 10);--ma-motif-hx:calc(var(--ma-travel-hover, 4px) * -1.25)" /></g>
          </g>
        </>
      )
    }
    {
      motif === 'plate' && (
        <>
          <g class="ma-motif__outl ma-motif__f1">
            <rect x="11.5" y="27" width="3.4" height="47" rx="1.7" />
            <rect x="8.5" y="27" width="1.7" height="12" rx="0.85" />
            <rect x="16.2" y="27" width="1.7" height="12" rx="0.85" />
            <rect x="8.5" y="37" width="9.4" height="2.6" rx="1.3" />
          </g>
          <g class="ma-motif__plate">
            <circle class="ma-motif__f1" cx="50" cy="50" r="29" />
            <circle class="ma-motif__fb" cx="50" cy="50" r="21" opacity="0.4" />
            <circle class="ma-motif__f2" cx="50" cy="50" r="11" />
            <circle class="ma-motif__f1" cx="46" cy="46.5" r="2.2" />
            <circle class="ma-motif__f1" cx="54.5" cy="52" r="1.6" />
            <circle class="ma-motif__fb" cx="48" cy="55" r="1.4" opacity="0.6" />
          </g>
          <g class="ma-motif__outr ma-motif__f1">
            <path d="M84.5 27c3.4 2.2 5 8.6 5 17v5h-5z" />
            <rect x="84.5" y="47" width="3.6" height="27" rx="1.8" />
          </g>
        </>
      )
    }
    {
      motif === 'wave' && (
        <>
          <g class="ma-motif__rise"><circle class="ma-motif__f2" cx="70" cy="27" r="11" /></g>
          <g stroke-width="3.4" stroke-linecap="round">
            <path class="ma-motif__s1 ma-motif__w1" d={wave(52)} />
            <path class="ma-motif__s2 ma-motif__w2" d={wave(63)} />
            <path class="ma-motif__s1 ma-motif__w1" d={wave(74)} />
            <path class="ma-motif__s2 ma-motif__w2" d={wave(85)} />
          </g>
        </>
      )
    }
    {
      motif === 'shapes' && (
        <>
          <circle class="ma-motif__f1 ma-motif__a" cx="36" cy="37" r="20" />
          <rect class="ma-motif__f2 ma-motif__b" x="46" y="44" width="34" height="34" rx="4" />
          <path class="ma-motif__ff ma-motif__c" d="M20 86L37 56L54 86Z" />
        </>
      )
    }
  </svg>
  {tile && caption && <span class="ma-motif__caption">{caption}</span>}
</div>

<style is:global>
  @layer components {
    :where(.ma-motif) { display: grid; place-items: center; color: var(--ma-block-text, currentColor); }
    :where(.ma-motif__svg) { width: 100%; height: 100%; overflow: visible; }
    /* the tile: the block's ground, the drawing at three quarters of the box, a light from the top left and a shade at
       the bottom right, so a flat drawing reads as an object in a picture */
    :where(.ma-motif--tile) { position: relative; overflow: hidden; width: 100%; height: 100%; background: var(--ma-block-ground, transparent); }
    :where(.ma-motif--tile .ma-motif__svg) { width: 74%; height: 74%; max-width: 30rem; }
    :where(.ma-motif--tile)::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(110% 75% at 12% 0%, oklch(1 0 0 / 20%), transparent 60%), radial-gradient(90% 70% at 100% 100%, oklch(0 0 0 / 10%), transparent 60%); }
    :where(.ma-motif__caption) { position: absolute; left: 0.875rem; bottom: 0.875rem; z-index: 1; padding: 0.4rem 0.7rem; border-radius: 999px; background: color-mix(in oklab, var(--background, var(--ma-panel)) 92%, transparent); color: var(--foreground, var(--ma-ink)); font-size: 0.6875rem; font-weight: 600; line-height: 1; letter-spacing: 0.08em; text-transform: uppercase; }

    /* the two drawing colours, the ground for cut-outs and the text for a dark accent: what a block or a tone names */
    :where(.ma-motif__f1) { fill: var(--ma-motif-1, currentColor); }
    :where(.ma-motif__f2) { fill: var(--ma-motif-2, color-mix(in oklab, currentColor 55%, transparent)); }
    :where(.ma-motif__fb) { fill: var(--ma-block-ground, var(--background, transparent)); }
    :where(.ma-motif__ff) { fill: var(--ma-block-text, currentColor); }
    :where(.ma-motif__s1) { fill: none; stroke: var(--ma-motif-1, currentColor); }
    :where(.ma-motif__s2) { fill: none; stroke: var(--ma-motif-2, color-mix(in oklab, currentColor 55%, transparent)); }
    :where(.ma-motif__pill) { fill: var(--ma-block-text, currentColor); }
    :where(.ma-motif__badge) { fill: var(--ma-block-ground, var(--background, transparent)); font: 700 3.6px/1 var(--ma-font-body, system-ui, sans-serif); letter-spacing: 0.04em; }

    /* the parts that move while a link around the drawing is hovered or focused: a few view-box units, on the preset's
       slow duration */
    :where(.ma-motif svg [class*='ma-motif__']) { transform-box: view-box; transition: translate var(--ma-duration-slow, 700ms) var(--ma-ease-out), rotate var(--ma-duration-slow, 700ms) var(--ma-ease-out); }
    :where(.ma-motif__spin, .ma-motif__plate) { transform-origin: 50px 50px; }
    :where(.ma-motif__b) { transform-origin: 63px 61px; rotate: 10deg; }
    :where(.ma-motif__heart) { transform-box: fill-box; transform-origin: 50% 100%; opacity: 0; }
    @media (hover: hover) and (pointer: fine) {
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__rise) { translate: 0 calc(var(--ma-travel-hover, 4px) * -1.25); }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__left) { translate: calc(var(--ma-travel-hover, 4px) * -1.25) 0; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__right) { translate: calc(var(--ma-travel-hover, 4px) * 1.25) 0; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__spin) { rotate: 28deg; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__up) { translate: 0 calc(var(--ma-travel-hover, 4px) * -0.75); }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__down) { translate: 0 calc(var(--ma-travel-hover, 4px) * 0.75); }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__plate) { rotate: -35deg; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__outl) { translate: calc(var(--ma-travel-hover, 4px) * -0.75) 0; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__outr) { translate: calc(var(--ma-travel-hover, 4px) * 0.75) 0; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__w1) { translate: calc(var(--ma-travel-hover, 4px) * -1.5) 0; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__w2) { translate: calc(var(--ma-travel-hover, 4px) * 1.5) 0; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__a) { translate: calc(var(--ma-travel-hover, 4px) * -0.75) calc(var(--ma-travel-hover, 4px) * -0.75); }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__b) { rotate: 24deg; }
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__c) { translate: calc(var(--ma-travel-hover, 4px) * 1) 0; }
      /* the hearts rise from the phone while it is hovered, three times, then rest: nothing loops on its own */
      :where(:is(:is(a, button, .ma-motif-host):is(:hover, :focus-visible), .ma-motif-host:has(:focus-visible)) .ma-motif:not([data-still]) .ma-motif__heart) { animation: ma-motif-heart calc(var(--ma-duration-slow, 700ms) * 4) var(--ma-ease-out) var(--ma-motif-d, 0s) 3; }
    }
    @keyframes ma-motif-heart {
      0% { translate: 0 0; scale: 0.5; opacity: 0; }
      18% { opacity: 1; }
      100% { translate: var(--ma-motif-hx, calc(var(--ma-travel-hover, 4px) * -0.5)) calc(var(--ma-travel-hover, 4px) * -6); scale: 1; opacity: 0; }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-motif svg [class*='ma-motif__']) { transition: none; }
      :where(.ma-motif__heart) { animation: none; opacity: 1; }
    }
  }
</style>

```
