Skip to content

Components / Backgrounds

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

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 9.2 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 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>
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/motif.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.

Motif.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>

Props

PropTypeDefaultDescription
name'sun' | 'gate' | 'flower' | 'grid' | 'phone' | 'plate' | 'wave' | 'shapes'noneThe drawing. Unset, seed picks one.
seedstring''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.
tilebooleanfalseFill 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.
block1 | 2 | 3 | 4 | 5noneThe 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.
captionstringnoneA short caption in a pill at the tile's lower left (tile only).
labelstringnoneWhat the picture shows, for a screen reader (role="img"). Unset, the drawing is decoration and hidden from it.
badgestring'LIVE'The word on the phone's badge.
stillbooleanfalseNo movement on hover.

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.

Why it is built this way

Replaces: Placeholder images (Framer templates) · Illustration packs (Humaaans, Open Peeps). See the migration table.

A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.

every browseralways

Dot Pattern

Backgrounds

A field of dots behind content, faded toward the edges, with an optional slow pulse.

every browseralways

Grid Pattern

Backgrounds

A hairline grid behind content, faded toward the edges, optionally drifting one cell at a time.

every browseralways

Skeleton

Loading

Placeholder lines, optionally with an avatar, shimmering while content loads.

every browseralways

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