Skip to content

Components / Backgrounds

Home Art

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. A seed varies the floors, the windows, the roof and the trees and draws the same building on every build; time lights the windows at dusk and at night and darkens the sky. As a tile it fills its box with the block's ground as the sky. Hovering or focusing a link around it lights a few more windows. 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.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. 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 HomeArt from '@moonarc/core/HomeArt';
---
<a href="/homes/12-linden-row/" class="block aspect-[4/3]">
  <HomeArt kind="house" seed="12 Linden Row" time="dusk" tile block={2} label="A two-storey house with a gable roof at dusk" />
</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/home-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.

HomeArt.astro
---
/**
 * HomeArt — 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 (a palette picks that by
 * contrast with the ground), with a shade, a light and the windows mixed from it. `seed` varies the drawing (the floors,
 * the windows, the roof, the trees) and draws the same one on every build; `time` lights the windows at dusk and at
 * night and darkens the sky. With `tile` it fills its box, the block's ground as the sky.
 *
 * Hovering or focusing a link around it lights a few more windows. Nothing moves on its own. Zero JavaScript.
 */
import type { HTMLAttributes } from 'astro/types';

const kinds = ['house', 'terrace', 'apartment', 'villa', 'cabin', 'tower', 'office', 'shop'] as const;
type Kind = (typeof kinds)[number];

interface Props extends HTMLAttributes<'div'> {
  /** The building. Unset, `seed` picks one. */
  kind?: Kind;
  /** A word the drawing is varied by: the same word, the same building. */
  seed?: string;
  /** Daylight, dusk (some windows lit, a warm sky) or night (most lit, a dark sky). */
  time?: 'day' | 'dusk' | 'night';
  /** Fill the box: the block's ground as the sky, the building on its ground line. */
  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 walls' colour, any CSS colour. Unset, the block's first motif colour. */
  colour?: string;
  /** The roof 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 building. */
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 G = 104; // the ground line, in a 160 by 120 box
const lit = time === 'night' ? 0.75 : time === 'dusk' ? 0.4 : 0;
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}"/>`);
/** A window: lit by time and seed; an unlit one is marked to light when a link around the drawing is pointed at. */
const win = (x: number, y: number, w: number, hh: number, rx = 0.8) => {
  const on = r() < lit;
  add(`<rect class="ma-home__w" data-on="${on ? '' : 'off'}" x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(hh)}" rx="${rx}"/>`);
};
const tree = (x: number, s = 1) => {
  rect('ma-home__d', x - 1.2 * s, G - 12 * s, 2.4 * s, 12 * s);
  add(`<circle class="ma-home__g" cx="${n(x)}" cy="${n(G - 17 * s)}" r="${n(9 * s)}"/><circle class="ma-home__gl" cx="${n(x - 3 * s)}" cy="${n(G - 20 * s)}" r="${n(4 * s)}"/>`);
};
const pine = (x: number, s = 1) => path('ma-home__g', `M${n(x)} ${n(G - 34 * s)}L${n(x + 9 * s)} ${n(G - 6 * s)}H${n(x - 9 * s)}Z`);
const door = (x: number, w = 9, hh = 15) => {
  rect('ma-home__d', x, G - hh, w, hh, 0.8);
  add(`<circle class="ma-home__l" cx="${n(x + w - 2.2)}" cy="${n(G - hh / 2)}" r="0.8"/>`);
};

if (art === 'house') {
  const w = 64 + Math.round(r() * 18);
  const x = 80 - w / 2;
  const top = G - 44;
  const roof = pick(['gable', 'hip', 'flat'] as const);
  if (r() < 0.6) rect('ma-home__s', x + w * 0.68, top - 22, 7, 16);
  if (roof === 'flat') rect('ma-home__a', x - 3, top - 4, w + 6, 5, 1);
  else path('ma-home__a', roof === 'gable' ? `M${n(x - 5)} ${top}L80 ${top - 26}L${n(x + w + 5)} ${top}Z` : `M${n(x - 5)} ${top}L${n(x + 14)} ${top - 20}H${n(x + w - 14)}L${n(x + w + 5)} ${top}Z`);
  rect('ma-home__b', x, top, w, 44);
  rect('ma-home__sh', x + w * 0.72, top, w * 0.28, 44);
  const cols = w > 74 ? 3 : 2;
  for (let i = 0; i < cols; i++) win(x + 8 + i * ((w - 16 - 11) / (cols - 1)), top + 7, 11, 11);
  door(x + 10);
  win(x + w - 25, G - 16, 13, 10);
  if (roof === 'gable') win(77, top - 14, 6, 7, 3);
  tree(r() < 0.5 ? x - 12 : x + w + 12, 0.85 + r() * 0.3);
} else if (art === 'terrace') {
  const units = 3;
  const w = 32;
  const x0 = 80 - (units * w) / 2;
  for (let u = 0; u < units; u++) {
    const x = x0 + u * w;
    const floors = 2 + (r() < 0.35 ? 1 : 0);
    const top = G - floors * 19 - 4;
    const cls = u === 1 ? 'ma-home__l' : u === 2 ? 'ma-home__s' : 'ma-home__b';
    path('ma-home__a', `M${n(x - 1)} ${top}L${n(x + w / 2)} ${top - 12}L${n(x + w + 1)} ${top}Z`);
    rect(cls, x, top, w, G - top);
    for (let f = 0; f < floors - 1; f++) { win(x + 5, top + 6 + f * 19, 9, 11); win(x + 18, top + 6 + f * 19, 9, 11); }
    door(x + 5, 8, 14);
    win(x + 18, G - 15, 9, 10);
  }
  rect('ma-home__s', x0 - 2, G - 2, units * w + 4, 2);
} else if (art === 'apartment') {
  const floors = 4 + Math.floor(r() * 3);
  const fh = 13;
  const w = 88;
  const x = 80 - w / 2;
  const top = G - floors * fh - 6;
  rect('ma-home__a', x + w * 0.62, top - 7, 18, 8);
  rect('ma-home__a', x - 2, top - 2, w + 4, 4, 1);
  rect('ma-home__b', x, top, w, G - top);
  rect('ma-home__sh', x + w * 0.5, top, w * 0.5, G - top);
  for (let f = 0; f < floors; f++) {
    const y = top + 5 + f * fh;
    for (let i = 0; i < 5; i++) win(x + 6 + i * 16.5, y, 9, 7);
    if (f % 2 === 1 && f < floors - 1) rect('ma-home__a', x + 3, y + 8.5, w - 6, 1.6);
  }
  rect('ma-home__d', 76, G - 9, 12, 9, 0.8);
  tree(x - 11, 0.8);
  tree(x + w + 11, 0.7 + r() * 0.2);
} else if (art === 'villa') {
  const w = 116;
  const x = 80 - w / 2;
  const up = r() < 0.7;
  rect('ma-home__b', x, G - 26, w, 26);
  rect('ma-home__a', x - 6, G - 29, w + 12, 4, 1);
  for (let i = 0; i < 5; i++) win(x + 6 + i * 21.5, G - 21, 17, 17, 0.6);
  if (up) {
    rect('ma-home__l', x + 18, G - 50, 62, 21);
    rect('ma-home__a', x + 12, G - 53, 74, 4, 1);
    for (let i = 0; i < 3; i++) win(x + 24 + i * 19, G - 46, 14, 13, 0.6);
  }
  rect('ma-home__pool', x + 6, G + 3, 58, 4, 2);
  const px = x + w + 4;
  add(`<path class="ma-home__ss" d="M${n(px)} ${G}Q${n(px - 2)} ${G - 20} ${n(px + 3)} ${G - 36}"/>`);
  path('ma-home__g', `M${n(px + 3)} ${G - 36}q-12 -2 -16 6q10 -6 16 -6q-6 -10 -16 -8q10 2 16 8q2 -11 12 -12q-9 4 -12 12q11 -4 15 4q-8 -6 -15 -4Z`);
} else if (art === 'cabin') {
  const w = 70;
  const x = 80 - w / 2;
  path('ma-home__a', `M${n(x - 4)} ${G}L80 ${G - 62}L${n(x + w + 4)} ${G}Z`);
  path('ma-home__b', `M${n(x + 4)} ${G}L80 ${G - 52}L${n(x + w - 4)} ${G}Z`);
  path('ma-home__w', `M66 ${G - 4}L80 ${G - 38}L94 ${G - 4}Z`);
  add(`<path class="ma-home__ws" d="M80 ${G - 38}V${G - 4}M70 ${G - 14}H90"/>`);
  if (lit) add(`<path class="ma-home__w" data-on="" d="M66 ${G - 4}L80 ${G - 38}L94 ${G - 4}Z"/>`);
  rect('ma-home__s', x - 8, G - 3, w + 16, 3);
  pine(x - 16, 1);
  pine(x - 26, 0.75);
  pine(x + w + 16, 0.9 + r() * 0.2);
} else if (art === 'tower') {
  const w = 46;
  const x = 80 - w / 2;
  const top = 12 + Math.round(r() * 8);
  rect('ma-home__a', x + 10, top - 8, w - 20, 8);
  rect('ma-home__b', x, top, w, G - top);
  rect('ma-home__sh', x + w * 0.55, top, w * 0.45, G - top);
  for (let y = top + 5; y < G - 12; y += 7) for (let i = 0; i < 4; i++) win(x + 4 + i * 10.5, y, 8, 4, 0.4);
  rect('ma-home__d', 74, G - 10, 12, 10, 0.6);
  rect('ma-home__l', x - 22, G - 34, 20, 34);
  for (let y = G - 30; y < G - 4; y += 7) win(x - 19, y, 14, 4, 0.4);
  rect('ma-home__s', x + w + 3, G - 24, 22, 24);
  for (let y = G - 20; y < G - 4; y += 7) win(x + w + 6, y, 16, 4, 0.4);
} else if (art === 'office') {
  const w = 112;
  const x = 80 - w / 2;
  const floors = 3;
  const top = G - floors * 17 - 6;
  rect('ma-home__b', x, top, w, G - top);
  rect('ma-home__a', x, top, w, 6);
  for (let f = 0; f < floors; f++) for (let i = 0; i < 7; i++) win(x + 4 + i * 15.6, top + 9 + f * 17, 13, 12, 0.4);
  rect('ma-home__a', 62, G - 18, 36, 3, 1);
  rect('ma-home__d', 70, G - 15, 20, 15);
  tree(x - 10, 0.8);
} else {
  const w = 88;
  const x = 80 - w / 2;
  const top = G - 52;
  rect('ma-home__b', x, top, w, 52);
  rect('ma-home__a', x - 2, top - 4, w + 4, 5, 1);
  win(x + 12, top + 7, 16, 12);
  win(x + 36, top + 7, 16, 12);
  win(x + 60, top + 7, 16, 12);
  rect('ma-home__a', x, top + 23, w, 6);
  for (let i = 0; i < 8; i++) path(i % 2 ? 'ma-home__p' : 'ma-home__a', `M${n(x + i * 11)} ${top + 29}h11l-1.5 6h-8Z`);
  win(x + 6, G - 16, 50, 16, 0.4);
  door(x + 64, 14, 18);
}

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

<div class:list={['ma-home', tile && 'ma-home--tile', className]} data-ma-block={block} data-kind={art} data-time={time} style={vars} {...rest}>
  <svg viewBox="0 0 160 120" class="ma-home__svg" preserveAspectRatio={tile ? 'xMidYMax slice' : undefined} focusable="false" {...a11y}>
    {sky && <rect class={sky} x="-200" y="-200" width="560" height="304" />}
    {time === 'night' && <g class="ma-home__stars"><circle cx="20" cy="18" r="0.8" /><circle cx="44" cy="10" r="0.6" /><circle cx="118" cy="14" r="0.8" /><circle cx="140" cy="30" r="0.6" /><circle cx="96" cy="8" r="0.6" /></g>}
    <rect class="ma-home__ground" x="-200" y={G} width="560" height="200" />
    <g set:html={out.join('')} />
  </svg>
</div>

<style is:global>
  @layer components {
    /* the walls' colour, and the shade, light, glass and deep tones mixed from it, so a colourway is one value */
    :where(.ma-home) {
      --_body: var(--ma-home-body, var(--ma-motif-1, var(--primary, currentColor)));
      --_accent: var(--ma-home-accent, var(--ma-motif-2, var(--accent, currentColor)));
      --_shade: color-mix(in oklab, var(--_body) 76%, #000);
      --_light: color-mix(in oklab, var(--_body) 58%, #fff);
      --_glass: color-mix(in oklab, var(--_body) 14%, #fff);
      --_deep: color-mix(in oklab, var(--_body) 32%, #17120e);
      --_lit: oklch(0.88 0.12 85);
      display: grid;
      place-items: end center;
    }
    :where(.ma-home__svg) { width: 100%; height: 100%; overflow: visible; }
    /* the tile: the block's ground is the sky, the drawing fills the box from its ground line up */
    :where(.ma-home--tile) { position: relative; overflow: hidden; width: 100%; height: 100%; background: var(--ma-block-ground, transparent); }
    :where(.ma-home--tile .ma-home__svg) { position: absolute; inset: 0; overflow: hidden; }
    :where(.ma-home__ground) { fill: color-mix(in oklab, var(--ma-block-ground, var(--_body)) 82%, #000); }
    :where(.ma-home:not(.ma-home--tile) .ma-home__ground) { fill: none; stroke: var(--_shade); stroke-width: 0.8; }
    :where(.ma-home__dusk) { fill: oklch(0.72 0.12 45 / 0.38); }
    :where(.ma-home__night) { fill: oklch(0.2 0.05 265 / 0.72); }
    :where(.ma-home__stars) { fill: #fff; opacity: 0.8; }
    :where(.ma-home__b) { fill: var(--_body); }
    :where(.ma-home__s) { fill: var(--_shade); }
    :where(.ma-home__sh) { fill: var(--_shade); fill-opacity: 0.35; }
    :where(.ma-home__l) { fill: var(--_light); }
    :where(.ma-home__a) { fill: var(--_accent); }
    :where(.ma-home__p) { fill: var(--_glass); }
    :where(.ma-home__d) { fill: var(--_deep); }
    :where(.ma-home__g) { fill: color-mix(in oklab, var(--_accent) 40%, oklch(0.55 0.12 150)); }
    :where(.ma-home__gl) { fill: color-mix(in oklab, var(--_accent) 30%, oklch(0.7 0.13 140)); }
    :where(.ma-home__ss) { fill: none; stroke: var(--_deep); stroke-width: 2.4; stroke-linecap: round; }
    :where(.ma-home__ws) { fill: none; stroke: var(--_shade); stroke-width: 1.4; }
    :where(.ma-home__pool) { fill: oklch(0.8 0.08 220); }
    :where(.ma-home__w) { fill: var(--_glass); transition: fill var(--ma-duration-slow, 400ms) var(--ma-ease-out, ease-out); }
    :where(.ma-home__w[data-on='']) { fill: var(--_lit); }
    :where(.ma-home[data-time='day'] .ma-home__w) { fill: color-mix(in oklab, var(--_glass) 78%, oklch(0.75 0.06 230)); }
    /* a link around the drawing, pointed at or focused: the unlit windows light, as a house does when someone is home */
    :where(a:is(:hover, :focus-visible) .ma-home__w[data-on='off']) { fill: var(--_lit); }
    @media (forced-colors: active) {
      :where(.ma-home__svg) { forced-color-adjust: none; }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-home__w) { transition: none; }
    }
  }
</style>

Props

PropTypeDefaultDescription
kind'house' | 'terrace' | 'apartment' | 'villa' | 'cabin' | 'tower' | 'office' | 'shop'noneThe building. Unset, the seed picks one.
seedstring''A word the drawing is varied by (the floors, the windows, the roof, the trees): the same word, the same building on every build.
time'day' | 'dusk' | 'night''day'Daylight; dusk, with some windows lit and a warm sky; or night, with most lit and a dark sky.
tilebooleanfalseFill the box: the block's ground is the sky and the drawing stands on its ground line.
block1 | 2 | 3 | 4 | 5noneThe colour block the tile paints (blocks.css). Unset, the tile takes the ground it sits on.
colourstringnoneThe walls, any CSS colour. Unset, the block's first motif colour.
accentstringnoneThe roof and the trim. Unset, the block's second motif colour.
labelstringnoneWhat the picture shows, for a screen reader. Unset, the drawing is decoration and hidden from it.

Reduced motion

The windows light at once on hover, with no fade.

With ClientRouter

Static SVG; nothing to rebind.

Why it is built this way

Replaces: Stock photographs of houses on listing templates. See the migration table.

Lens

Pointer

A round magnifier that follows the pointer over a picture.

every browserpointerhover

Motif

Backgrounds

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.

every browserhover

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