Skip to content

Components / Backgrounds

Room Art

A room drawn flat, for a stay that has no photograph of it yet: a single, a double, a twin, a suite, a family room, a bunk room or a loft, in the colours of the block or tone it sits on, with a shade, a light and a pale tone mixed from them. A seed varies the drawing (which side the window is, the rug, the plant, the picture on the wall) and draws the same room on every build; time sets the sky in the window, day, dusk or night, and lights the lamp after dark. As a tile it fills its box with the block's ground as the wall. Hovering or focusing a link around it switches the lamp on. Nothing moves on its own. 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.5 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. Elsewhere: nothing to fall back from.

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 RoomArt from '@moonarc/core/RoomArt';
---
<a href="/rooms/garden-double/" class="room-card">
  <RoomArt kind="double" seed="Garden double" time="dusk" block={2} tile />
  <span>Garden double, from €140 a night</span>
</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/room-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.

RoomArt.astro
---
/**
 * RoomArt — a room drawn flat, for a stay that has no photograph of it yet: a single, a double, a twin, a suite, a family room, a
 * bunk room or a loft, in the colours of the block or tone it sits on, with the window's sky by the time of day. `seed`
 * varies the drawing (where the window is, the rug, the plant, the picture on the wall) and draws the same room on
 * every build; `time` sets the sky and lights the lamp at dusk and at night. With `tile` it fills its box, the block's
 * ground as the wall.
 *
 * Hovering or focusing a link around it switches the lamp on. Nothing moves on its own. Zero JavaScript.
 */
import type { HTMLAttributes } from 'astro/types';

const kinds = ['single', 'double', 'twin', 'suite', 'family', 'bunk', 'loft'] as const;
type Kind = (typeof kinds)[number];

interface Props extends HTMLAttributes<'div'> {
  /** The room. Unset, `seed` picks one. */
  kind?: Kind;
  /** A word the drawing is varied by: the same word, the same room. */
  seed?: string;
  /** The sky in the window: daylight, dusk (the lamp on) or night (the lamp on, the moon out). */
  time?: 'day' | 'dusk' | 'night';
  /** Fill the box: the block's ground as the wall, the floor along the bottom. */
  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;
  /** The furniture's colour, any CSS colour. Unset, the block's first motif colour. */
  colour?: string;
  /** The bedding and the trim. Unset, the block's second motif colour. */
  accent?: string;
  /** What the picture shows, for a screen reader. Unset, the drawing is decoration and hidden from it. */
  label?: string;
}

const { kind, seed = '', time = 'day', tile = false, block, colour, accent, label, class: className, style, ...rest } = Astro.props;
let h = 2166136261;
for (const c of `${kind ?? ''}:${seed}`) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
/** A seeded number in [0, 1): the same seed, the same room. */
const r = () => ((h = Math.imul(h ^ (h >>> 15), 2246822507) + 0x6d2b79f5) >>> 0) / 4294967296;
const pick = <T,>(a: readonly T[]): T => a[Math.floor(r() * a.length)]!;
const art: Kind = kind ?? pick(kinds);

const F = 86; // the floor line, in a 160 by 120 box
const out: string[] = [];
const add = (s: string) => out.push(s);
const n = (v: number) => +v.toFixed(1);
const rect = (cls: string, x: number, y: number, w: number, hh: number, rx = 0) => add(`<rect class="${cls}" x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(hh)}"${rx ? ` rx="${rx}"` : ''}/>`);
const path = (cls: string, d: string) => add(`<path class="${cls}" d="${d}"/>`);

// the window, on the side the bed is not: its sky by the time of day
const right = r() < 0.5;
const wx = right ? 104 : 18;
const wide = art === 'suite' ? 44 : 36;
const sky = time === 'night' ? 'ma-room__night' : time === 'dusk' ? 'ma-room__dusk' : 'ma-room__day';
const windowAt = (x: number, w: number) => {
  rect('ma-room__frame', x - 2, 14, w + 4, 42, 1.5);
  rect(sky, x, 16, w, 38);
  if (time === 'night') add(`<circle class="ma-room__moon" cx="${n(x + w * 0.7)}" cy="26" r="4"/>`);
  else if (time === 'day') add(`<circle class="ma-room__sun" cx="${n(x + w * 0.3)}" cy="27" r="5"/>`);
  else add(`<rect class="ma-room__glow" x="${n(x)}" y="40" width="${n(w)}" height="14"/>`);
  path('ma-room__bars', `M${n(x + w / 2)} 16V54M${n(x)} 35H${n(x + w)}`);
  rect('ma-room__sill', x - 4, 56, w + 8, 3, 1);
};
// the room's furniture: a bed of its width at x, its pillows and its cover
const bed = (x: number, w: number, top = F - 26) => {
  rect('ma-room__b', x, top - 14, 4, F - top + 14);
  rect('ma-room__b', x, top - 14, w * 0.18, 16, 2);
  rect('ma-room__l', x + 3, top, w - 3, 10, 2);
  for (let p = 0; p < (w > 40 ? 2 : 1); p++) rect('ma-room__p', x + 6 + p * (w / 2 - 4), top - 4, w / 2 - 8 > 14 ? 14 : w / 2 - 8, 6, 3);
  rect('ma-room__a', x + w * 0.38, top + 2, w * 0.62, 12, 2);
  rect('ma-room__s', x + 4, F - 6, 3, 6);
  rect('ma-room__s', x + w - 4, F - 6, 3, 6);
};
const lampAt = (x: number) => {
  rect('ma-room__s', x - 7, F - 20, 14, 20, 1.5);
  rect('ma-room__d', x - 0.6, F - 34, 1.2, 14);
  path('ma-room__shade', `M${n(x - 6)} ${F - 34}L${n(x - 3.5)} ${F - 42}H${n(x + 3.5)}L${n(x + 6)} ${F - 34}Z`);
  add(`<circle class="ma-room__lamp" data-on="${time === 'day' ? 'off' : ''}" cx="${n(x)}" cy="${F - 36}" r="13"/>`);
};
const plant = (x: number) => {
  path('ma-room__d', `M${n(x - 5)} ${F}L${n(x - 6)} ${F - 9}H${n(x + 6)}L${n(x + 5)} ${F}Z`);
  add(`<ellipse class="ma-room__g" cx="${n(x - 4)}" cy="${F - 16}" rx="4" ry="8" transform="rotate(-20 ${n(x - 4)} ${F - 16})"/><ellipse class="ma-room__gl" cx="${n(x + 4)}" cy="${F - 17}" rx="4" ry="8" transform="rotate(22 ${n(x + 4)} ${F - 17})"/>`);
};

if (art === 'loft') path('ma-room__roof', `M-10 -10H170V2L${right ? 40 : 120} 30L${right ? -10 : 170} 2Z`);
windowAt(wx, wide);
if (r() < 0.7) {
  const px = right ? 30 : 118;
  rect('ma-room__frame', px, 20, 18, 14, 1);
  rect(r() < 0.5 ? 'ma-room__a' : 'ma-room__l', px + 2, 22, 14, 10);
}
// the rug, under everything on the floor
add(`<ellipse class="ma-room__rug" cx="${right ? 56 : 104}" cy="${F + 12}" rx="${n(32 + r() * 10)}" ry="7"/>`);
if (art === 'twin') {
  bed(right ? 8 : 72, 30);
  lampAt(right ? 50 : 114);
  bed(right ? 60 : 124, 30);
} else if (art === 'single') {
  // one narrow bed against the wall, a desk and its chair where a second bed would be
  bed(right ? 10 : 118, 32);
  const dx = right ? 62 : 64;
  rect('ma-room__b', dx, F - 26, 34, 3, 1);
  rect('ma-room__s', dx + 2, F - 23, 2.5, 23);
  rect('ma-room__s', dx + 29.5, F - 23, 2.5, 23);
  rect('ma-room__a', dx + 10, F - 18, 12, 3, 1);
  rect('ma-room__s', dx + 11, F - 15, 2, 15);
  rect('ma-room__s', dx + 19, F - 15, 2, 15);
  rect('ma-room__s', dx + 19, F - 30, 2, 15);
  lampAt(right ? 50 : 110);
} else if (art === 'family') {
  bed(right ? 8 : 102, 50);
  lampAt(right ? 66 : 92);
  bed(right ? 76 : 58, 26);
} else if (art === 'bunk') {
  const x = right ? 16 : 84;
  rect('ma-room__b', x, F - 56, 4, 56);
  rect('ma-room__b', x + 52, F - 56, 4, 56);
  for (const top of [F - 52, F - 22]) {
    rect('ma-room__l', x + 4, top, 48, 8, 1.5);
    rect('ma-room__p', x + 7, top - 4, 13, 5, 2.5);
    rect('ma-room__a', x + 22, top + 1, 30, 9, 1.5);
  }
  path('ma-room__ladder', `M${x + 44} ${F}V${F - 52}M${x + 50} ${F}V${F - 52}M${x + 44} ${F - 10}H${x + 50}M${x + 44} ${F - 20}H${x + 50}M${x + 44} ${F - 30}H${x + 50}M${x + 44} ${F - 40}H${x + 50}`);
  lampAt(right ? 84 : 66);
} else {
  const w = art === 'loft' ? 66 : 60;
  const x = right ? 14 : 146 - w;
  bed(x, w, art === 'loft' ? F - 18 : F - 26);
  lampAt(right ? x + w + 12 : x - 12);
  if (art === 'suite') {
    const ax = right ? 112 : 18;
    rect('ma-room__b', ax, F - 22, 28, 22, 4);
    rect('ma-room__a', ax + 3, F - 30, 22, 14, 3);
    rect('ma-room__s', ax - 2, F - 26, 5, 18, 2);
    rect('ma-room__s', ax + 25, F - 26, 5, 18, 2);
  }
}
if (r() < 0.6 && art !== 'suite') plant(right ? 150 : 10);

const a11y = label ? { role: 'img' as const, 'aria-label': label } : { 'aria-hidden': 'true' as const };
const vars = [colour && `--ma-room-body:${colour}`, accent && `--ma-room-accent:${accent}`, typeof style === 'string' && style].filter(Boolean).join(';') || undefined;
---

<div class:list={['ma-room', tile && 'ma-room--tile', className]} data-ma-block={block} data-kind={art} data-time={time} style={vars} {...rest}>
  <svg viewBox="0 0 160 120" class="ma-room__svg" preserveAspectRatio={tile ? 'xMidYMax slice' : undefined} focusable="false" {...a11y}>
    <rect class="ma-room__wall" x="-200" y="-200" width="560" height={200 + F} />
    <rect class="ma-room__floor" x="-200" y={F} width="560" height="200" />
    <rect class="ma-room__skirt" x="-200" y={F - 2} width="560" height="2" />
    <g set:html={out.join('')} />
  </svg>
</div>

<style is:global>
  @layer components {
    /* the furniture's colour, and the shade, light, pale and deep tones mixed from it, so a colourway is one value */
    :where(.ma-room) {
      --_body: var(--ma-room-body, var(--ma-motif-1, var(--primary, currentColor)));
      --_accent: var(--ma-room-accent, var(--ma-motif-2, var(--accent, currentColor)));
      --_shade: color-mix(in oklab, var(--_body) 76%, #000);
      --_light: color-mix(in oklab, var(--_body) 22%, #fff);
      --_pale: color-mix(in oklab, var(--_body) 8%, #fff);
      --_deep: color-mix(in oklab, var(--_body) 32%, #17120e);
      --_wall: var(--ma-block-ground, color-mix(in oklab, var(--_body) 10%, #fff));
      --_lit: oklch(0.9 0.12 85);
      display: grid;
      place-items: end center;
    }
    :where(.ma-room__svg) { width: 100%; height: 100%; overflow: visible; }
    :where(.ma-room--tile) { position: relative; overflow: hidden; width: 100%; height: 100%; background: var(--_wall); }
    :where(.ma-room--tile .ma-room__svg) { position: absolute; inset: 0; overflow: hidden; }
    :where(.ma-room__wall) { fill: var(--_wall); }
    :where(.ma-room:not(.ma-room--tile) .ma-room__wall) { fill: none; }
    :where(.ma-room__floor) { fill: color-mix(in oklab, var(--_wall) 74%, var(--_deep)); }
    :where(.ma-room__skirt) { fill: color-mix(in oklab, var(--_wall) 60%, var(--_deep)); }
    :where(.ma-room__roof) { fill: color-mix(in oklab, var(--_wall) 86%, var(--_deep)); }
    :where(.ma-room__rug) { fill: color-mix(in oklab, var(--_accent) 55%, var(--_wall)); }
    :where(.ma-room__frame) { fill: var(--_pale); stroke: var(--_shade); stroke-width: 0.8; }
    :where(.ma-room__sill) { fill: var(--_light); }
    :where(.ma-room__bars) { fill: none; stroke: var(--_pale); stroke-width: 1.6; }
    :where(.ma-room__day) { fill: oklch(0.86 0.06 230); }
    :where(.ma-room__dusk) { fill: oklch(0.76 0.11 50); }
    :where(.ma-room__night) { fill: oklch(0.26 0.06 265); }
    :where(.ma-room__sun) { fill: oklch(0.95 0.09 95); }
    :where(.ma-room__glow) { fill: oklch(0.68 0.15 30 / 0.55); }
    :where(.ma-room__moon) { fill: oklch(0.96 0.03 95); }
    :where(.ma-room__b) { fill: var(--_body); }
    :where(.ma-room__s) { fill: var(--_shade); }
    :where(.ma-room__l) { fill: var(--_light); }
    :where(.ma-room__p) { fill: var(--_pale); }
    :where(.ma-room__a) { fill: var(--_accent); }
    :where(.ma-room__d) { fill: var(--_deep); }
    :where(.ma-room__shade) { fill: var(--_light); }
    :where(.ma-room__ladder) { fill: none; stroke: var(--_shade); stroke-width: 1.6; stroke-linecap: round; }
    :where(.ma-room__g) { fill: color-mix(in oklab, var(--_accent) 40%, oklch(0.55 0.12 150)); }
    :where(.ma-room__gl) { fill: color-mix(in oklab, var(--_accent) 30%, oklch(0.7 0.13 140)); }
    /* the lamp's light: off by day, on at dusk and at night, and on when a link around the drawing is pointed at */
    :where(.ma-room__lamp) { fill: var(--_lit); opacity: 0; transition: opacity var(--ma-duration-slow, 400ms) var(--ma-ease-out, ease-out); }
    :where(.ma-room__lamp[data-on='']) { opacity: 0.45; }
    :where(a:is(:hover, :focus-visible) .ma-room__lamp) { opacity: 0.45; }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-room__lamp) { transition: none; }
    }
  }
</style>

Props

PropTypeDefaultDescription
kind'single' | 'double' | 'twin' | 'suite' | 'family' | 'bunk' | 'loft'noneThe room. Unset, the seed picks one.
seedstring''A word the drawing is varied by: the same word, the same room.
time'day' | 'dusk' | 'night''day'The sky in the window; the lamp is on at dusk and at night.
tilebooleanfalseFill the box: the block's ground as the wall, the floor along the bottom.
block1 | 2 | 3 | 4 | 5noneThe block the tile paints; unset, the ground it sits on.
colourstringnoneThe furniture's colour. Unset, the block's first motif colour.
accentstringnoneThe bedding and the trim. Unset, the block's second motif colour.
labelstringnoneWhat the picture shows, for a screen reader. Unset, it is decoration and hidden.

Reduced motion

The lamp switches on at once.

With ClientRouter

No script: it works on any page however it was reached.

Why it is built this way

Replaces: Stock room photographs as placeholders. See the migration table.

Home Art

Backgrounds

A building drawn flat, for a listing that has no photograph yet: a house, a terrace, an apartment block, a villa, a cabin, a tower, an office or a shop, in the colour of the block or tone it sits on, with a shade, a light and the windows mixed from it.

every browserpointer

A picture that resolves from blocks as it scrolls into view: a grid of squares in one colour lies over the slot, and each square clears on its own stretch of the picture's entry, picked by a seed, so the picture comes through in a scatter and stands whole by the time it is a third of the way up the screen.

Chrome 115 · not Firefox · Safari 26scroll

Portrait Art

Backgrounds

A drawn portrait for a slot that has no photograph yet: a speaker, an artist, a host, a member of a team.

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

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