Component
Illustration
Flat shape illustrations for heroes, section headers and card covers. A theme without its own art draws the base art in its colours.
- #arc
- #component
- #illustration
- #hero
- #header
- #cover
- #svg
- #animation
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/illustration.jsonFiles
components/arc/illustration/base-set.tscomponents/arc/illustration/illustration-svg.tsxcomponents/arc/illustration/illustration.module.csscomponents/arc/illustration/illustration.tsxcomponents/arc/illustration/spec.ts
Registry items it pulls in
Source
View as Markdownbase-set.tsbase-set.tsbase-set.tsts
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.tsxillustration-svg.tsxillustration-svg.tsxtsx
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.cssillustration.module.cssillustration.module.csscss
/*
* 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.tsxillustration.tsxillustration.tsxtsx
"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.tsspec.tsspec.tsts
/**
* 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>>;
Used by
- Auth panelBlock
A centred sign-in panel with the providers stacked rather than ranked.
- Contact sectionBlock
A contact section offering a validated form, channel list, or offices with live local time.
- FAQ sectionBlock
An FAQ section in accordion, categorized columns, and live search variants.
- Not foundBlock
The page for a broken or moved link: a 4-0-4 drawn in shapes, a plain message and a way back.
- Site footerBlock
A site footer in columns, minimal, and large logo variants with an optional newsletter.