A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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.
npx shadcn@latest add https://moonarc.dev/r/motif.jsonThe 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 — 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
| 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. |
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
- 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). See the migration table.
Related
Dot Pattern
BackgroundsA field of dots behind content, faded toward the edges, with an optional slow pulse.
Grid Pattern
BackgroundsA hairline grid behind content, faded toward the edges, optionally drifting one cell at a time.
Skeleton
LoadingPlaceholder lines, optionally with an avatar, shimmering while content loads.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown