Illustration (custom — themeable SVG illustration display) — spec¶
Generic wrapper for displaying a system illustration with theming, sizing, accessibility, and reduced-motion support. Used directly when
EmptyState/Result/OnboardingSteparen't the right fit. Pairs withknowledge/illustration/svg-optimization.md.
Purpose¶
When you want to drop a system illustration into a screen — feature explainer, onboarding card, marketing surface — without the empty-state layout. Illustration provides:
- Registry-backed name — typesafe lookup from the system's illustration set.
- Theming via
currentColor+ CSS variables. - Responsive sizing — width or height; the other auto-scales via aspect ratio.
- Accessibility — decorative by default; meaningful on opt-in.
- Lottie escape hatch — if the illustration is animated, delegate to
LottiePlayer.
Anatomy¶
┌──────────────────┐
│ │
│ [SVG render] │ ← from registry
│ │
└──────────────────┘
OR
┌──────────────────┐
│ │
│ [Lottie render] │ ← when name maps to a Lottie source
│ │
└──────────────────┘
API¶
<Illustration name="onboarding-welcome" size="lg" />
<Illustration
name="payment-success"
size={200}
label="결제가 완료되었습니다" // makes meaningful (not decorative)
/>
const heroMoneyFlowSize = { desktop: 480, mobile: 240 };
<Illustration name="hero-money-flow" size={heroMoneyFlowSize} />
| Prop | Type | Default | Description |
|---|---|---|---|
name |
IllustrationName |
— | Registry key. Required. Typesafe via TS union. |
size |
"sm" \| "md" \| "lg" \| "xl" \| number \| { desktop: number; mobile: number } |
"md" |
Display size in px. Object form for responsive. |
label |
string |
undefined |
If set, illustration is meaningful (role="img" + aria-label). Default: decorative (aria-hidden). |
tone |
"brand" \| "neutral" \| "success" \| "warning" \| "danger" |
"brand" |
Color theme via CSS variable cascade |
as |
"div" \| "span" \| "figure" |
"div" |
Wrapper element |
className |
string |
— | Additional classes |
Sizes¶
| token | px |
|---|---|
sm |
80 |
md (default) |
120 |
lg |
200 |
xl |
320 |
For responsive: pass { desktop, mobile } object. The component renders both via CSS clamp():
.illustration[data-size-responsive] {
width: clamp(var(--mobile-size), 30vw, var(--desktop-size));
}
Tones¶
tone maps to a CSS variable setting the illustration's currentColor:
.illustration[data-tone="brand"] { color: var(--color-brand-default); }
.illustration[data-tone="neutral"] { color: var(--color-fg-muted); }
.illustration[data-tone="success"] { color: var(--color-success-default); }
.illustration[data-tone="warning"] { color: var(--color-warning-default); }
.illustration[data-tone="danger"] { color: var(--color-error-default); }
The illustration's SVG must use currentColor for the themable element. Multi-color illustrations also use CSS variables (var(--color-bg-illo)).
Contrast contract: each tone token must clear at least 3:1 against --color-bg-default for non-text graphic strokes/fills, and any nearby explanatory text must keep the normal 4.5:1 body-text contrast target from knowledge/a11y/contrast.md.
Usage context¶
Use Illustration as a reusable visual-language primitive when the layout is owned by another surface: onboarding panels, fintech confirmation screens, feature explainers, or Korean payment-success moments where the surrounding copy already carries the meaning. Use label only when the image itself adds information not repeated in text; otherwise keep it decorative so screen-reader output stays concise.
Registry¶
// illustrations/registry.ts
import { OnboardingWelcome } from "./svg/OnboardingWelcome";
import { PaymentSuccess } from "./svg/PaymentSuccess";
import { HeroMoneyFlow } from "./svg/HeroMoneyFlow";
export const illustrationRegistry = {
// Spot illustrations
"onboarding-welcome": { type: "svg", component: OnboardingWelcome },
"payment-success": { type: "svg", component: PaymentSuccess },
"filter-empty": { type: "svg", component: FilterEmpty },
// Hero illustrations
"hero-money-flow": { type: "svg", component: HeroMoneyFlow },
"hero-onboarding": { type: "svg", component: HeroOnboarding },
// Animated (Lottie)
"celebration": { type: "lottie", src: "/illustrations/celebration.json", poster: "/illustrations/celebration.png" },
} as const;
export type IllustrationName = keyof typeof illustrationRegistry;
The registry is the only place new illustrations get added. Engineers can't ship an illustration not in the registry — enforces governance.
States¶
| State | Visual |
|---|---|
| Default (decorative) | Renders illustration with brand tone |
| Meaningful (label set) | Same render; adds role="img" and aria-label |
| Reduced motion + animated illustration | Static frame from poster |
| Loading (Lottie source) | Poster while JSON streams; cross-fade when ready |
| Error (illustration name not in registry) | Dev: throw. Prod: fallback to generic illustration. |
Tokens consumed¶
--color-brand-default
--color-fg-muted
--color-success-default
--color-warning-default
--color-error-default
--color-bg-illo (multi-color illustrations)
--motion-fast (poster → animation cross-fade)
Accessibility¶
Default — decorative¶
Screen readers skip. Used when surrounding text already explains.
Meaningful — label set¶
<div class="illustration" role="img" aria-label="결제가 완료되었습니다">
<svg aria-hidden="true">...</svg>
</div>
The wrapper carries the label. Inner SVG stays aria-hidden to avoid double-announcement.
Animated illustrations¶
If name maps to a Lottie source, delegates to LottiePlayer which handles prefers-reduced-motion. See examples/component-lottie-player.md.
Implementation¶
import { illustrationRegistry, type IllustrationName } from "./registry";
import { LottiePlayer } from "./LottiePlayer";
interface Props {
name: IllustrationName;
size?: "sm" | "md" | "lg" | "xl" | number | { desktop: number; mobile: number };
label?: string;
tone?: "brand" | "neutral" | "success" | "warning" | "danger";
as?: "div" | "span" | "figure";
className?: string;
}
const SIZE_MAP = { sm: 80, md: 120, lg: 200, xl: 320 };
export function Illustration({
name,
size = "md",
label,
tone = "brand",
as: Tag = "div",
className,
}: Props) {
const entry = illustrationRegistry[name];
if (!entry) {
if (process.env.NODE_ENV !== "production") {
throw new Error(`Illustration "${name}" not in registry`);
}
return null; // or fallback
}
const dims = typeof size === "number"
? { width: size, height: size }
: typeof size === "string"
? { width: SIZE_MAP[size], height: SIZE_MAP[size] }
: { responsive: size };
const a11y = label
? { role: "img", "aria-label": label }
: { "aria-hidden": "true" };
return (
<Tag
className={cn("illustration", className)}
data-tone={tone}
data-size-responsive={dims.responsive ? "" : undefined}
style={
dims.responsive
? {
"--desktop-size": `${dims.responsive.desktop}px`,
"--mobile-size": `${dims.responsive.mobile}px`,
}
: { width: dims.width, height: dims.height }
}
{...a11y}
>
{entry.type === "svg" ? (
<entry.component aria-hidden="true" />
) : (
<LottiePlayer src={entry.src} poster={entry.poster} ariaLabel={label ?? ""} />
)}
</Tag>
);
}
Performance¶
- SVG illustrations: inlined as React components via SVGR / vite-plugin-svgr; treeshaken automatically.
- Lottie illustrations: lazy-loaded via
LottiePlayer's built-in lazy strategy. - For 50+ illustrations in registry: import on-demand to avoid shipping all in main bundle:
Wrap consumers in <Suspense fallback={<IllustrationSkeleton width={width} height={height} />}>.
Edge cases¶
- Name not in registry (typo): TS catches at compile time. Runtime: throw in dev, fallback in prod.
- Responsive size on SSR: server renders both sizes; CSS
clamp()resolves on client paint. No layout shift if dimensions are reserved. - Dark mode: registry's SVGs use
currentColor+ CSS variables. Tone variable cascades through dark theme. No per-illustration dark variant needed if SVG is built right. - RTL languages: most illustrations are visually symmetric; if not, mark in registry with
flippable: trueand applytransform: scaleX(-1)in RTL contexts. - Print: illustrations using
currentColorprint in current text color. For colored print, use named colors in registry's CSS layer.
Don't¶
- Don't pass arbitrary SVG nodes — extend the registry.
- Don't bake tone color in the SVG file. Use
currentColor. - Don't size illustrations with raw px from the consumer's css. Use the
sizeprop. - Don't render an
<img src=".svg">— that's the wrong escape. Use the registry; SVG is inlined for theming. - Don't auto-loop animated illustrations on every screen. Battery + a11y.
- Don't omit
labelif the illustration carries the only meaning on the screen. Defaults are decorative.
References¶
No upstream component matches. Most libraries either ship icon systems (different concern) or expect raw <img> / <svg> for illustrations. This component formalizes the registry + theming pattern.
Cross-reference¶
knowledge/illustration/illustration-systems.md— system foundationknowledge/illustration/svg-optimization.md— SVG productionknowledge/illustration/spot-illustrations.md— illustration typesexamples/component-empty-state.md— common consumerexamples/component-lottie-player.md— animated branchexamples/component-hero-block.md— hero illustration consumer