# 일러스트

히어로, 섹션 헤더, 카드 커버에 쓰는 도형 일러스트예요. 테마가 직접 그린 그림이 없으면 베이스 그림을 테마 색으로 그려요.

- Type: component
- Theme: base
- Tags: arc, component, illustration, hero, header, cover, svg, animation
- English title: Illustration
- URL: https://dsui.vercel.app/items/illustration

## Used by

- [로그인 패널](https://dsui.vercel.app/items/auth-panel): 가운데 놓이는 로그인 패널이에요. 제공자 버튼을 세로로 쌓아요.
- [문의 섹션](https://dsui.vercel.app/items/contact-section): 문의 양식, 연락 채널, 지점 안내 중에서 고를 수 있는 연락처 섹션이에요.
- [자주 묻는 질문](https://dsui.vercel.app/items/faq-section): 아코디언, 분류 열, 검색 세 가지 방식으로 질문과 답을 보여 주는 섹션이에요.
- [404 페이지](https://dsui.vercel.app/items/not-found): 페이지를 찾지 못했을 때 보여 주는 블록이에요. 4, 0, 4를 도형으로 쓴 일러스트와 돌아갈 길을 함께 안내해요.
- [사이트 푸터](https://dsui.vercel.app/items/site-footer): 열 링크, 미니멀, 로고 세 가지 모양의 사이트 하단 영역이에요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| animated | boolean | Animated | true |

## Source

### base-set.ts

```ts
import type { IllustrationKey, IllustrationSpec, Motion, Role, Shape } from "./spec";

type Opts = Partial<{ enter: number; motion: Motion; delay: number; origin: readonly [number, number]; opacity: number; blur: number }>;

export const rect = (x: number, y: number, w: number, h: number, fill: Role, r = 50, o: Opts = {}): Shape => ({ kind: "rect", x, y, w, h, r, fill, ...o });
/** A pill is a rect whose radius is half its short side. */
export const pill = (x: number, y: number, w: number, h: number, fill: Role, o: Opts = {}): Shape => rect(x, y, w, h, fill, Math.min(w, h) / 2, o);
export const path = (d: string, fill: Role, o: Opts = {}): Shape => ({ kind: "path", d, fill, ...o });
export const dot = (cx: number, cy: number, r: number, fill: Role, o: Opts = {}): Shape => ({ kind: "circle", cx, cy, r, fill, ...o });

export const spec = (key: IllustrationKey, width: number, height: number, shapes: Shape[]): IllustrationSpec => ({ key, width, height, shapes });

const shift = (shapes: Shape[], dx: number): Shape[] =>
  shapes.map((s) => {
    if (s.kind === "rect") return { ...s, x: s.x + dx };
    if (s.kind === "circle" || s.kind === "ellipse") return { ...s, cx: s.cx + dx };
    return s;
  });

const recolor = (shapes: Shape[], map: Partial<Record<Role, Role>>): Shape[] => shapes.map((s) => ({ ...s, fill: map[s.fill] ?? s.fill }));

/** One 480-wide module of the header rail; the second copy swaps blue and yellow. */
const railModule = (): Shape[] => [
  rect(-60, 56, 260, 220, "blue", 50, { enter: 0 }),
  dot(100, 120, 34, "paper", { enter: 0, motion: "float" }),
  pill(56, 176, 120, 28, "yellow", { enter: 0 }),
  rect(200, -60, 190, 200, "yellow", 50, { enter: 1 }),
  dot(250, 76, 13, "onYellow", { enter: 1, motion: "blink" }),
  dot(292, 76, 13, "onYellow", { enter: 1, motion: "blink", delay: 0.4 }),
  dot(346, 76, 22, "paper", { enter: 1, motion: "pulse" }),
  rect(200, 140, 120, 140, "ink", 50, { enter: 2 }),
  dot(260, 240, 44, "yellow", { enter: 2, motion: "float" }),
  rect(320, 140, 160, 140, "mute", 50, { enter: 3 }),
  pill(380, 160, 40, 80, "blue", { enter: 3, motion: "sway" }),
];

const stepFills: Role[] = ["mute", "blue", "mute", "yellow", "mute", "ink"];


/** A skyline of pills and tiles standing on the bottom edge, for the band under a site footer. */
function footerSkyline(): Shape[] {
  const items: { x: number; w: number; h: number; fill: Role; pill?: boolean; dot?: Role; slot?: Role }[] = [
    { x: -30, w: 170, h: 110, fill: "mute", pill: true },
    { x: 150, w: 130, h: 170, fill: "blue", pill: true, dot: "paper" },
    { x: 290, w: 210, h: 90, fill: "yellow", dot: "onYellow" },
    { x: 510, w: 150, h: 190, fill: "ink", pill: true, dot: "yellow" },
    { x: 670, w: 190, h: 130, fill: "mute", slot: "blue" },
    { x: 870, w: 140, h: 160, fill: "yellow", pill: true, dot: "paper" },
    { x: 1020, w: 200, h: 100, fill: "blue", dot: "paper" },
    { x: 1230, w: 130, h: 180, fill: "ink", pill: true, dot: "blue" },
    { x: 1370, w: 110, h: 120, fill: "mute", pill: true },
  ];
  return items.flatMap((it, i): Shape[] => {
    const y = 200 - it.h;
    const body = rect(it.x, y, it.w, it.h + 60, it.fill, it.pill ? it.w / 2 : 45, { enter: i });
    const out: Shape[] = [body];
    if (it.dot) out.push(dot(it.x + it.w / 2, y + (it.pill ? it.w / 2 : it.h / 2), it.pill ? it.w * 0.2 : 20, it.dot, { enter: i, motion: i % 2 ? "float" : "pulse", delay: i * 0.2 }));
    if (it.slot) out.push(pill(it.x + it.w / 2 - 20, y + 24, 40, 70, it.slot, { enter: i, motion: "sway" }));
    return out;
  });
}

export const BASE_ILLUSTRATIONS: Record<IllustrationKey, IllustrationSpec> = {
  "hero-collage": spec("hero-collage", 960, 540, [
    rect(92, 87, 434, 520, "blue", 50, { enter: 0 }),
    dot(309, 246, 65, "paper", { enter: 0, motion: "float" }),
    pill(136, 377, 347, 44, "yellow", { enter: 0, motion: "drift" }),
    rect(526, -40, 450, 301, "yellow", 50, { enter: 1 }),
    dot(613, 109, 22, "onYellow", { enter: 1, motion: "blink" }),
    dot(699, 109, 22, "onYellow", { enter: 1, motion: "blink", delay: 0.5 }),
    dot(830, 131, 65, "paper", { enter: 1, motion: "pulse" }),
    rect(526, 261, 260, 320, "ink", 50, { enter: 2 }),
    dot(656, 540, 105, "yellow", { enter: 2, motion: "float" }),
    rect(786, 261, 190, 320, "mute", 50, { enter: 3 }),
    pill(830, 311, 87, 174, "blue", { enter: 3, motion: "sway" }),
  ]),
  "hero-stage": spec("hero-stage", 960, 540, [
    rect(-40, 90, 480, 520, "yellow", 50, { enter: 0 }),
    dot(130, 190, 22, "onYellow", { enter: 0, motion: "blink" }),
    dot(216, 190, 22, "onYellow", { enter: 0, motion: "blink", delay: 0.5 }),
    dot(300, 330, 72, "paper", { enter: 0, motion: "float" }),
    rect(480, -40, 520, 300, "blue", 50, { enter: 1 }),
    dot(700, 130, 62, "paper", { enter: 1, motion: "pulse" }),
    pill(560, 190, 260, 40, "yellow", { enter: 1, motion: "drift" }),
    rect(480, 260, 200, 320, "mute", 50, { enter: 2 }),
    pill(540, 330, 80, 160, "yellow", { enter: 2, motion: "sway" }),
    rect(680, 260, 330, 320, "ink", 50, { enter: 3 }),
    dot(845, 540, 100, "blue", { enter: 3, motion: "float" }),
  ]),
  "hero-stack": spec("hero-stack", 960, 540, [
    rect(140, 320, 170, 300, "mute", 85, { enter: 0 }),
    rect(330, 230, 170, 400, "blue", 85, { enter: 1 }),
    dot(415, 315, 38, "paper", { enter: 1, motion: "float" }),
    rect(520, 150, 170, 480, "yellow", 85, { enter: 2 }),
    dot(605, 235, 24, "onYellow", { enter: 2, motion: "blink" }),
    rect(710, 70, 170, 560, "ink", 85, { enter: 3 }),
    dot(795, 155, 38, "yellow", { enter: 3, motion: "pulse" }),
  ]),
  "hero-orbit": spec("hero-orbit", 960, 540, [
    dot(480, 270, 230, "mute", { enter: 0 }),
    dot(480, 270, 150, "blue", { enter: 1 }),
    dot(480, 270, 66, "paper", { enter: 2, motion: "pulse" }),
    dot(480, 36, 30, "yellow", { enter: 3, motion: "orbit", origin: [480, 270] }),
    dot(480, 516, 16, "ink", { enter: 3, motion: "orbit", origin: [480, 270], delay: 0 }),
    dot(150, 90, 22, "yellow", { enter: 4, motion: "float" }),
    dot(820, 450, 22, "blue", { enter: 4, motion: "float", delay: 0.6 }),
  ]),
  "header-rail": spec("header-rail", 960, 240, [
    ...railModule(),
    ...shift(recolor(railModule(), { blue: "yellow", yellow: "blue", onYellow: "paper" }), 480),
  ]),
  "header-steps": spec(
    "header-steps",
    960,
    240,
    [90, 120, 150, 180, 210, 240].flatMap((h, i): Shape[] => {
      const x = 60 + i * 136;
      const y = 240 - h;
      const fill = stepFills[i]!;
      const bar = rect(x, y, 112, h + 60, fill, 56, { enter: i });
      if (fill === "blue") return [bar, dot(x + 56, y + 56, 22, "paper", { enter: i, motion: "float" })];
      if (fill === "yellow") return [bar, dot(x + 56, y + 56, 22, "onYellow", { enter: i, motion: "pulse" })];
      if (fill === "ink") return [bar, dot(x + 56, y + 56, 22, "yellow", { enter: i, motion: "float" })];
      return [bar];
    }),
  ),
  "card-collage": spec("card-collage", 480, 320, [
    rect(60, 48, 220, 300, "blue", 40, { enter: 0 }),
    dot(170, 128, 38, "paper", { enter: 0, motion: "float" }),
    pill(90, 214, 160, 28, "yellow", { enter: 0, motion: "drift" }),
    rect(280, -20, 220, 150, "yellow", 40, { enter: 1 }),
    dot(330, 60, 12, "onYellow", { enter: 1, motion: "blink" }),
    dot(370, 60, 12, "onYellow", { enter: 1, motion: "blink", delay: 0.5 }),
    dot(430, 70, 30, "paper", { enter: 1, motion: "pulse" }),
    rect(280, 130, 120, 220, "ink", 40, { enter: 2 }),
    dot(340, 320, 56, "yellow", { enter: 2, motion: "float" }),
    rect(400, 130, 100, 220, "mute", 40, { enter: 3 }),
    pill(428, 160, 44, 110, "blue", { enter: 3, motion: "sway" }),
  ]),
  "card-dots": spec("card-dots", 480, 320, [
    rect(40, 40, 400, 240, "yellow", 48, { enter: 0 }),
    dot(120, 110, 20, "onYellow", { enter: 1, motion: "blink" }),
    dot(190, 110, 20, "onYellow", { enter: 1, motion: "blink", delay: 0.5 }),
    dot(330, 160, 60, "paper", { enter: 2, motion: "pulse" }),
    pill(100, 200, 160, 36, "blue", { enter: 3, motion: "drift" }),
  ]),
  "card-pill": spec("card-pill", 480, 320, [
    rect(120, 30, 240, 340, "mute", 56, { enter: 0 }),
    pill(200, 80, 80, 200, "blue", { enter: 1, motion: "sway" }),
    dot(240, 120, 24, "paper", { enter: 1, motion: "pulse" }),
    dot(400, 90, 22, "yellow", { enter: 2, motion: "float" }),
    dot(90, 250, 14, "ink", { enter: 2, motion: "blink" }),
  ]),
  "card-arch": spec("card-arch", 480, 320, [
    rect(80, 40, 320, 340, "ink", 56, { enter: 0 }),
    dot(240, 320, 110, "yellow", { enter: 1, motion: "float" }),
    dot(240, 120, 40, "paper", { enter: 2, motion: "pulse" }),
    dot(150, 200, 10, "mute", { enter: 2, motion: "blink" }),
    dot(330, 200, 10, "mute", { enter: 2, motion: "blink", delay: 0.5 }),
  ]),
  "page-404": spec("page-404", 960, 400, [
    rect(80, 60, 240, 360, "yellow", 50, { enter: 0 }),
    pill(120, 90, 40, 150, "onYellow", { enter: 0 }),
    pill(120, 200, 170, 40, "onYellow", { enter: 0, motion: "drift" }),
    pill(240, 90, 40, 250, "onYellow", { enter: 0 }),
    dot(480, 200, 120, "blue", { enter: 1, motion: "pulse" }),
    dot(480, 200, 56, "paper", { enter: 1 }),
    pill(540, 338, 100, 30, "mute", { enter: 2, motion: "drift", delay: 0.4 }),
    dot(400, 352, 12, "yellow", { enter: 2, motion: "blink" }),
    rect(640, 60, 240, 360, "ink", 50, { enter: 3 }),
    pill(680, 90, 40, 150, "yellow", { enter: 3 }),
    pill(680, 200, 170, 40, "yellow", { enter: 3, motion: "drift", delay: 0.3 }),
    pill(800, 90, 40, 250, "yellow", { enter: 3 }),
  ]),
  "page-faq": spec("page-faq", 960, 400, [
    rect(100, 70, 400, 130, "blue", 65, { enter: 0, motion: "float" }),
    dot(190, 135, 15, "paper", { enter: 0, motion: "blink" }),
    dot(245, 135, 15, "paper", { enter: 0, motion: "blink", delay: 0.4 }),
    dot(300, 135, 15, "paper", { enter: 0, motion: "blink", delay: 0.8 }),
    rect(380, 230, 440, 130, "yellow", 65, { enter: 1, motion: "float", delay: 0.5 }),
    pill(440, 262, 260, 18, "onYellow", { enter: 1 }),
    pill(440, 304, 170, 18, "onYellow", { enter: 1 }),
    rect(660, -40, 220, 190, "mute", 50, { enter: 2 }),
    dot(770, 70, 44, "ink", { enter: 2, motion: "pulse" }),
    dot(770, 70, 14, "yellow", { enter: 2 }),
    dot(70, 300, 16, "ink", { enter: 3, motion: "blink" }),
    dot(310, 300, 10, "blue", { enter: 3, motion: "blink", delay: 0.6 }),
  ]),
  "page-login": spec("page-login", 960, 400, [
    rect(-40, 90, 260, 360, "yellow", 50, { enter: 0 }),
    dot(80, 200, 24, "onYellow", { enter: 0, motion: "blink" }),
    dot(150, 200, 24, "onYellow", { enter: 0, motion: "blink", delay: 0.5 }),
    pill(396, 24, 36, 190, "ink", { enter: 1 }),
    pill(528, 24, 36, 190, "ink", { enter: 1 }),
    pill(396, 24, 168, 36, "ink", { enter: 1 }),
    rect(340, 150, 280, 300, "blue", 50, { enter: 2 }),
    dot(480, 250, 38, "paper", { enter: 2, motion: "pulse" }),
    pill(466, 262, 28, 90, "paper", { enter: 2 }),
    dot(750, 130, 42, "yellow", { enter: 3, motion: "float" }),
    pill(782, 120, 120, 20, "yellow", { enter: 3, motion: "drift" }),
    pill(850, 140, 20, 44, "yellow", { enter: 3 }),
    rect(680, 250, 340, 200, "mute", 50, { enter: 4 }),
    pill(740, 290, 110, 36, "blue", { enter: 4, motion: "drift", delay: 0.4 }),
  ]),
  "page-contact": spec("page-contact", 960, 400, [
    rect(60, 110, 150, 320, "ink", 44, { enter: 0 }),
    dot(135, 170, 12, "yellow", { enter: 0, motion: "blink" }),
    pill(100, 360, 70, 12, "mute", { enter: 0 }),
    rect(280, 110, 400, 280, "blue", 44, { enter: 1 }),
    path("M280 150 L480 280 L680 150 L680 130 Q680 110 660 110 L300 110 Q280 110 280 130 Z", "yellow", { enter: 2, motion: "float" }),
    dot(480, 290, 26, "paper", { enter: 2, motion: "pulse" }),
    pill(740, 70, 190, 64, "mute", { enter: 3, motion: "drift" }),
    dot(780, 102, 12, "blue", { enter: 3 }),
    pill(740, 160, 130, 64, "yellow", { enter: 4, motion: "drift", delay: 0.5 }),
    dot(780, 192, 12, "onYellow", { enter: 4 }),
    dot(900, 300, 30, "blue", { enter: 4, motion: "float" }),
  ]),
  splash: spec("splash", 600, 600, [
    rect(80, 80, 220, 220, "blue", 70, { enter: 0 }),
    dot(190, 190, 54, "paper", { enter: 0, motion: "pulse" }),
    rect(300, 80, 220, 220, "yellow", 70, { enter: 1 }),
    dot(370, 150, 16, "onYellow", { enter: 1, motion: "blink" }),
    dot(450, 150, 16, "onYellow", { enter: 1, motion: "blink", delay: 0.5 }),
    rect(80, 300, 220, 220, "ink", 70, { enter: 2 }),
    dot(190, 520, 70, "yellow", { enter: 2, motion: "float" }),
    rect(300, 300, 220, 220, "mute", 70, { enter: 3 }),
    pill(386, 346, 48, 128, "blue", { enter: 3, motion: "sway" }),
    dot(300, 30, 16, "yellow", { enter: 4, motion: "orbit", origin: [300, 300] }),
    dot(300, 574, 10, "blue", { enter: 4, motion: "orbit", origin: [300, 300] }),
  ]),
  "footer-band": spec("footer-band", 1440, 200, footerSkyline()),
  "card-tabs": spec("card-tabs", 480, 320, [
    pill(60, 52, 360, 56, "blue", { enter: 0, motion: "drift" }),
    dot(88, 80, 16, "paper", { enter: 0 }),
    pill(100, 132, 320, 56, "yellow", { enter: 1, motion: "drift", delay: 0.3 }),
    dot(128, 160, 16, "onYellow", { enter: 1 }),
    pill(60, 212, 280, 56, "mute", { enter: 2, motion: "drift", delay: 0.6 }),
    dot(88, 240, 16, "blue", { enter: 2 }),
  ]),
};

```

### illustration-svg.tsx

```tsx
import type { CSSProperties, SVGAttributes } from "react";
import type { IllustrationSpec, Shape } from "./spec";

export interface IllustrationSvgProps extends Omit<SVGAttributes<SVGSVGElement>, "children"> {
  spec: IllustrationSpec;
  /** Play the entrance and ambient motion. Off by default; reduced-motion users never see it. */
  animated?: boolean;
  /** Accessible name. Without it the illustration is decorative and hidden from assistive tech. */
  title?: string;
}

function shapeStyle(shape: Shape): CSSProperties | undefined {
  const style: Record<string, string | number> = {};
  if (shape.delay !== undefined) style["--d"] = shape.delay;
  if (shape.origin) {
    style["--ox"] = `${shape.origin[0]}px`;
    style["--oy"] = `${shape.origin[1]}px`;
  }
  if (shape.blur) style.filter = `blur(${shape.blur}px)`;
  return Object.keys(style).length ? (style as CSSProperties) : undefined;
}

function ShapeNode({ shape }: { shape: Shape }) {
  const common = { "data-f": shape.fill, "data-m": shape.motion, opacity: shape.opacity, style: shapeStyle(shape) };
  switch (shape.kind) {
    case "rect":
      return <rect x={shape.x} y={shape.y} width={shape.w} height={shape.h} rx={shape.r} {...common} />;
    case "circle":
      return <circle cx={shape.cx} cy={shape.cy} r={shape.r} {...common} />;
    case "ellipse":
      return (
        <ellipse
          cx={shape.cx}
          cy={shape.cy}
          rx={shape.rx}
          ry={shape.ry}
          transform={shape.rotate ? `rotate(${shape.rotate} ${shape.cx} ${shape.cy})` : undefined}
          {...common}
        />
      );
    case "path":
      return <path d={shape.d} {...common} />;
  }
}

/**
 * The svg of one illustration, with no hooks and no stylesheet import, so it renders on the server, in a test and in the
 * export script. `Illustration` adds the active theme's art and the stylesheet.
 */
export function IllustrationSvg({ spec, animated = false, title, ...rest }: IllustrationSvgProps) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox={`0 0 ${spec.width} ${spec.height}`}
      width={spec.width}
      height={spec.height}
      role={title ? "img" : undefined}
      aria-hidden={title ? undefined : true}
      data-animated={animated ? "true" : undefined}
      data-illustration={spec.key}
      {...rest}
    >
      {title ? <title>{title}</title> : null}
      {spec.shapes.map((shape, index) =>
        shape.enter === undefined ? (
          <ShapeNode key={index} shape={shape} />
        ) : (
          <g key={index} data-enter="" style={{ "--i": shape.enter } as CSSProperties}>
            <ShapeNode shape={shape} />
          </g>
        ),
      )}
    </svg>
  );
}

```

### illustration.module.css

```css
/*
 * Illustration styles. Written against data attributes and the single .root class only, so scripts/gen-illustrations.ts
 * can inline this file into standalone .svg exports by replacing `.root` with `svg`.
 * Colours come from the Arc tokens (--accent, --surface, ...), so a season theme and dark mode recolour them; the fallbacks are the base theme's light values.
 */
.root {
  display: block;
  width: 100%;
  height: auto;
  overflow: hidden;
  --ill-blue: var(--accent, #2563eb);
  --ill-yellow: var(--highlight, #fbbf24);
  --ill-ink: var(--inverse, #252729);
  --ill-mute: var(--border, #dfe5eb);
  --ill-paper: var(--surface, #ffffff);
  --ill-onYellow: var(--highlight-foreground, #252729);
  --ill-glow: var(--glow, #fbbf24);
  --ill-blueSubtle: var(--accent-subtle, #dbeafe);
  --ill-yellowSubtle: var(--highlight-subtle, #fef3c7);
}

.root [data-f="blue"] { fill: var(--ill-blue); }
.root [data-f="yellow"] { fill: var(--ill-yellow); }
.root [data-f="ink"] { fill: var(--ill-ink); }
.root [data-f="mute"] { fill: var(--ill-mute); }
.root [data-f="paper"] { fill: var(--ill-paper); }
.root [data-f="onYellow"] { fill: var(--ill-onYellow); }
.root [data-f="glow"] { fill: var(--ill-glow); }
.root [data-f="blueSubtle"] { fill: var(--ill-blueSubtle); }
.root [data-f="yellowSubtle"] { fill: var(--ill-yellowSubtle); }

/* Motion only plays when the root says data-animated="true". Entrance first, then the ambient loop. */
.root [data-m] { transform-box: fill-box; transform-origin: center; }
.root [data-m="orbit"] { transform-box: view-box; transform-origin: var(--ox, 50%) var(--oy, 50%); }

.root[data-animated="true"] [data-enter] {
  animation: rise 700ms var(--ds-ease-out, cubic-bezier(0.22, 1, 0.36, 1)) both;
  animation-delay: calc(var(--i, 0) * 90ms);
}
.root[data-animated="true"] [data-m="float"] { animation: float 5s ease-in-out infinite; animation-delay: calc(var(--d, 0) * 1s + 0.8s); }
.root[data-animated="true"] [data-m="drift"] { animation: drift 6s ease-in-out infinite; animation-delay: calc(var(--d, 0) * 1s + 0.8s); }
.root[data-animated="true"] [data-m="pulse"] { animation: pulse 3.6s ease-in-out infinite; animation-delay: calc(var(--d, 0) * 1s + 0.8s); }
.root[data-animated="true"] [data-m="blink"] { animation: blink 2.6s ease-in-out infinite; animation-delay: calc(var(--d, 0) * 1s + 0.8s); }
.root[data-animated="true"] [data-m="sway"] { animation: sway 4.5s ease-in-out infinite; animation-delay: calc(var(--d, 0) * 1s + 0.8s); }
.root[data-animated="true"] [data-m="orbit"] { animation: orbit 28s linear infinite; animation-delay: calc(var(--d, 0) * 1s); }

@keyframes rise { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: none; } }
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
@keyframes drift { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(14px); } }
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@keyframes sway { 0%, 100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }
@keyframes orbit { to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
  .root[data-animated="true"] * { animation: none !important; }
}

```

### illustration.tsx

```tsx
"use client";

import { useThemeAssets } from "../theme/provider";
import { BASE_ILLUSTRATIONS } from "./base-set";
import { IllustrationSvg, type IllustrationSvgProps } from "./illustration-svg";
import styles from "./illustration.module.css";
import type { IllustrationKey, IllustrationSpec } from "./spec";

export interface IllustrationProps extends Omit<IllustrationSvgProps, "spec"> {
  /** Which illustration. Hero, header and card sizes are listed in `ILLUSTRATION_META`. */
  name: IllustrationKey;
  /** Draws this spec instead of the active theme's art. For showing a particular theme's picture outside its provider, such as a theme gallery. */
  spec?: IllustrationSpec;
}

/**
 * A flat illustration for heroes, section headers and card covers. It draws the active theme's own art when the theme has
 * made one, and the base art when it has not (see `ThemeAssetsProvider`). Colours always follow the theme's tokens.
 * Size it with the width of its container: the aspect ratio is fixed by the illustration.
 */
export function Illustration({ name, spec: given, animated = false, className, ...rest }: IllustrationProps) {
  const themed = useThemeAssets().illustrations?.[name];
  const spec = given ?? themed ?? BASE_ILLUSTRATIONS[name];
  return <IllustrationSvg spec={spec} animated={animated} className={className ? `${styles.root} ${className}` : styles.root} {...rest} />;
}

```

### spec.ts

```ts
/**
 * Illustration spec: a declarative list of flat shapes. One spec drives the React renderer, the
 * exported .svg files and the Figma page, so the three never drift apart.
 */

/** Colour roles. Each maps to a theme token, so a theme recolours every illustration it inherits. */
export const ROLES = ["blue", "yellow", "ink", "mute", "paper", "onYellow", "glow", "blueSubtle", "yellowSubtle"] as const;
export type Role = (typeof ROLES)[number];

/** role -> theme token and the base light value used when no theme is loaded. */
export const ROLE_TOKENS: Record<Role, { token: string; light: string; dark: string }> = {
  blue: { token: "--accent", light: "#2563eb", dark: "#6b9bff" },
  yellow: { token: "--highlight", light: "#fbbf24", dark: "#fbbf24" },
  ink: { token: "--inverse", light: "#252729", dark: "#252729" },
  mute: { token: "--border", light: "#dfe5eb", dark: "#2a2f35" },
  paper: { token: "--surface", light: "#ffffff", dark: "#101214" },
  onYellow: { token: "--highlight-foreground", light: "#252729", dark: "#252729" },
  glow: { token: "--glow", light: "#fbbf24", dark: "#fbbf24" },
  blueSubtle: { token: "--accent-subtle", light: "#dbeafe", dark: "#172554" },
  yellowSubtle: { token: "--highlight-subtle", light: "#fef3c7", dark: "#422006" },
};

/** Ambient loops that play only when `animated` is on. Entrance (`enter`) is separate. */
export const MOTIONS = ["float", "drift", "pulse", "blink", "sway", "orbit"] as const;
export type Motion = (typeof MOTIONS)[number];

interface ShapeBase {
  fill: Role;
  /** Entrance stagger index: the shape rises into place after `enter * 90ms`. Omit for no entrance. */
  enter?: number;
  motion?: Motion;
  /** Extra delay (s) before the ambient loop starts. */
  delay?: number;
  /** Rotation centre for `orbit`, in viewBox units. */
  origin?: readonly [number, number];
  opacity?: number;
  /** Gaussian blur in viewBox units, for soft glows. */
  blur?: number;
}

export type Shape =
  | (ShapeBase & { kind: "rect"; x: number; y: number; w: number; h: number; r: number })
  | (ShapeBase & { kind: "circle"; cx: number; cy: number; r: number })
  | (ShapeBase & { kind: "ellipse"; cx: number; cy: number; rx: number; ry: number; rotate?: number })
  | (ShapeBase & { kind: "path"; d: string });

export interface IllustrationSpec {
  key: IllustrationKey;
  width: number;
  height: number;
  shapes: readonly Shape[];
}

export const ILLUSTRATION_KEYS = [
  "hero-collage",
  "hero-stage",
  "hero-stack",
  "hero-orbit",
  "header-rail",
  "header-steps",
  "card-collage",
  "card-dots",
  "card-pill",
  "card-arch",
  "card-tabs",
  "page-404",
  "page-faq",
  "page-login",
  "page-contact",
  "splash",
  "footer-band",
] as const;
export type IllustrationKey = (typeof ILLUSTRATION_KEYS)[number];

/** page: the top of a 404, FAQ, login or contact page. splash: the first screen of the app. footer: a full-width band under the site footer. */
export type IllustrationKind = "hero" | "header" | "card" | "page" | "splash" | "footer";

export const ILLUSTRATION_META: Record<
  IllustrationKey,
  { kind: IllustrationKind; title: { ko: string; en: string }; summary: { ko: string; en: string } }
> = {
  "hero-collage": { kind: "hero", title: { ko: "히어로 콜라주", en: "Hero collage" }, summary: { ko: "네 개의 둥근 면이 맞물린 기본 히어로.", en: "The default hero: four rounded tiles that interlock." } },
  "hero-stage": { kind: "hero", title: { ko: "히어로 무대", en: "Hero stage" }, summary: { ko: "큰 노랑 면을 앞세운 비대칭 히어로.", en: "An asymmetric hero led by a large yellow tile." } },
  "hero-stack": { kind: "hero", title: { ko: "히어로 스택", en: "Hero stack" }, summary: { ko: "알약 기둥이 계단처럼 오르는 성장 히어로.", en: "Pill columns climbing like steps, for growth stories." } },
  "hero-orbit": { kind: "hero", title: { ko: "히어로 궤도", en: "Hero orbit" }, summary: { ko: "겹친 원 둘레를 작은 원이 도는 히어로.", en: "Concentric circles with a small one orbiting." } },
  "header-rail": { kind: "header", title: { ko: "헤더 레일", en: "Header rail" }, summary: { ko: "타일이 가로로 이어지는 섹션 헤더 띠.", en: "A band of tiles running across a section header." } },
  "header-steps": { kind: "header", title: { ko: "헤더 계단", en: "Header steps" }, summary: { ko: "알약이 오르막으로 늘어서는 섹션 헤더 띠.", en: "Pills rising left to right along a section header." } },
  "card-collage": { kind: "card", title: { ko: "카드 콜라주", en: "Card collage" }, summary: { ko: "히어로 콜라주를 카드 비율로 줄인 커버.", en: "The hero collage condensed for a card cover." } },
  "card-dots": { kind: "card", title: { ko: "카드 점", en: "Card dots" }, summary: { ko: "노랑 면에 점과 원, 알약을 얹은 커버.", en: "A yellow tile with dots, a circle and a pill." } },
  "card-pill": { kind: "card", title: { ko: "카드 알약", en: "Card pill" }, summary: { ko: "세로 알약 하나가 중심인 조용한 커버.", en: "A quiet cover centred on one vertical pill." } },
  "card-arch": { kind: "card", title: { ko: "카드 아치", en: "Card arch" }, summary: { ko: "어두운 면 위로 반원이 떠오르는 커버.", en: "A half circle rising over a dark tile." } },
  "page-404": { kind: "page", title: { ko: "404 페이지", en: "404 page" }, summary: { ko: "4, 0, 4를 도형으로 쓴 길 잃은 페이지의 상단 그림.", en: "A 4-0-4 built from shapes, for the top of a not-found page." } },
  "page-faq": { kind: "page", title: { ko: "FAQ 페이지", en: "FAQ page" }, summary: { ko: "질문과 답이 오가는 말풍선으로 그린 FAQ 상단 그림.", en: "Question and answer bubbles for the top of an FAQ page." } },
  "page-login": { kind: "page", title: { ko: "로그인 페이지", en: "Login page" }, summary: { ko: "자물쇠와 열쇠로 그린 로그인 상단 그림.", en: "A lock and key for the top of a sign-in page." } },
  "page-contact": { kind: "page", title: { ko: "연락 페이지", en: "Contact page" }, summary: { ko: "편지 봉투와 대화 말풍선으로 그린 문의 상단 그림.", en: "An envelope and chat bubbles for the top of a contact page." } },
  splash: { kind: "splash", title: { ko: "스플래시", en: "Splash" }, summary: { ko: "네 개의 면이 모여 하나가 되고 점이 도는 첫 화면 그림. 항상 animated로 써요.", en: "Four tiles gather into one while dots orbit. Meant to be played animated." } },
  "footer-band": { kind: "footer", title: { ko: "푸터 띠", en: "Footer band" }, summary: { ko: "사이트 푸터 아래를 가로로 채우는 도시 윤곽 띠.", en: "A skyline band that runs full width under the site footer." } },
  "card-tabs": { kind: "card", title: { ko: "카드 탭", en: "Card tabs" }, summary: { ko: "알약 세 줄이 목록처럼 쌓인 커버.", en: "Three pills stacked like a list." } },
};

export type IllustrationSet = Partial<Record<IllustrationKey, IllustrationSpec>>;

```
