Nakodo is built as a field guide to YouTube creators, so the interface is meant to look like a naturalist's notebook: a kingfisher called Alcy, tagged specimen entries, plate numbers, hatching instead of grey fills. All of it is drawn in React components rather than exported as assets, and the rule that made the whole set maintainable is that a spot illustration takes no colour props at all.
This is the design-engineering half of the product. There is no clever algorithm in here, just a small set of constraints that stopped the illustrations from drifting apart as the set grew.
One grid, one stroke width, three variables
Every drawing is on a 96 unit grid with 1.5 unit strokes, and may use exactly three colours:
// Every spot is drawn on a 96 x 96 grid with 1.5-unit strokes, and uses only
// --illo-line, --illo-fill and --illo-spot, so a context (paper, cyanotype
// panel, dark) is handled by swapping those three variables.
export const LINE = "var(--illo-line)";
export const FILL = "var(--illo-fill)";
export const SPOT = "var(--illo-spot)";
Three variables is the entire palette discipline. No drawing names a colour, no drawing accepts a colour prop, and no drawing knows whether it is on paper or on a dark panel. Eighteen spots later I can still add one without opening any of the others, which is not something I could say about my earlier attempts at this where each component took a tone prop and they slowly diverged.
The wrapper is where everything shared lives:
const PX: Record<IlloSize, number> = { s: 64, m: 120, l: 240 };
export function Illo({ size = "m", className, title, uid, children }: IlloProps & { uid: string; children: ReactNode }) {
const px = PX[size];
const a11y = title ? { role: "img", "aria-labelledby": `${uid}-title` } : { "aria-hidden": true };
return (
<svg
viewBox="0 0 96 96"
width={px}
height={px}
className={className}
fill="none"
stroke={LINE}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...a11y}
>
{title ? <title id={`${uid}-title`}>{title}</title> : null}
{children}
</svg>
);
}
Three sizes rather than free scaling, because a 1.5 unit stroke on a 96 unit grid reads differently at 64 pixels than at 240, and an illustration set where every instance picks its own size is an illustration set that looks like a collage. fill="none" and stroke on the root mean individual paths are mostly bare <path d=...>, so the drawing code stays readable as drawing code.
Accessibility has to be the default, not the option
The two lines I care most about in that component:
const a11y = title ? { role: "img", "aria-labelledby": `${uid}-title` } : { "aria-hidden": true };
Most of these drawings are decorative. A kingfisher next to a heading that already says "Survey" adds nothing for a screen reader and announcing "image" there is pure noise. So no title means hidden, and passing a title is the deliberate act that promotes a drawing to content.
Getting this backwards is the common version: the alt text is optional, so most spots end up announced as "img" or, worse, as a file name. Making silence the default and description the opt-in means the only labelled illustrations are the ones where somebody decided the label was worth writing.
focusable="false" is there for old Internet Explorer and Edge behaviour where SVGs entered the tab order. It costs nine characters and removes a class of keyboard-trap bug report.
The ids have to survive url(#...)
Hatching, gradients and clip paths need ids, and ids have to be unique per instance because the same bird can appear twice on a page. useId() is the right source, with one wrinkle:
/** A useId() value that is safe inside url(#...). */
export function useIlloId(): string {
return useId().replace(/[^a-zA-Z0-9_-]/g, "");
}
export const url = (id: string) => `url(#${id})`;
React's generated ids are not promised to be safe inside a CSS-ish reference. React 18 produced values containing colons, and url(#:r1:) does not resolve, which shows up as an illustration that renders with no hatching and no error anywhere. Stripping everything outside [A-Za-z0-9_-] makes it independent of what React decides to generate. On the live site today those ids come out as _S_3_-hatch and _S_3_-body; you can see them in the inspector on the landing page.
Hatching instead of grey
There is no grey in this set. Tone comes from hatching, which is one pattern definition:
/** Hatching: 0.75-unit lines at 45 degrees, 3 units apart. */
export function Hatch({ id }: { id: string }) {
return (
<pattern id={id} width="3" height="3" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="1.5" y1="0" x2="1.5" y2="3" stroke={LINE} strokeWidth="0.75" />
</pattern>
);
}
Half the main stroke width, three units apart, rotated 45 degrees, and crucially stroked with the same --illo-line as the outlines. That is why shading inverts along with everything else instead of becoming a smudge on a dark background. A hardcoded #ccc fill would have looked identical in the first mockup and wrong in every context after it.
The payoff: inverting by context, with no props
Here is the part you can check yourself. The "How it works" section of nakodo.app is a cyanotype panel: deep blue, light ink. The four step illustrations inside it are the same components used on the pale sections of the page, passed nothing different.
/* Cyanotype panels: a surface, not an accent. Illustrations invert by variable swap. */
@layer components {
.panel {
background-color: var(--panel);
color: var(--panel-foreground);
--illo-line: var(--panel-foreground);
--illo-fill: var(--panel);
--hatch: color-mix(in oklch, var(--panel-foreground) 32%, transparent);
/* ...plus border, link, ring, muted-foreground for the same context */
}
}
Open the console on the live page and read the computed value in each context:
getComputedStyle(document.documentElement).getPropertyValue('--illo-line')
// a near-black ink colour
getComputedStyle(document.querySelector('.panel')).getPropertyValue('--illo-line')
// a near-white paper colour
When I ran that just now the root gave me lab(13.4665% -2.01512 -11.7954) and the panel gave lab(96.7997% .35131 4.58077): same drawings, opposite ink. Then restroke the entire page at once:
document.documentElement.style.setProperty('--illo-line', 'red');
Every spot on the page re-inks, including the hatching, because there is exactly one variable to change. Five illustrations on the landing page respond to that single line, four of them inside the panel. That console snippet is the test for whether this pattern is actually working: if any drawing stays black, something in it hardcoded a colour.
The same mechanism covers dark mode, with one trap worth repeating. In our stylesheet the dark theme restates every semantic token with a literal value rather than re-pointing the palette, so the comment in globals.css is a warning to our future selves:
/* Every value is redeclared: a var() resolved on :root wouldn't pick up dark overrides. */
.dark {
--background: oklch(0.185 0.028 252);
/* ...and every other token, including the three illustration variables */
}
If you set up your dark theme that way, a :root token defined as var(--some-palette-token) keeps its light value even though the theme changed, because the palette token it points at was never overridden. The fix is boring and worth it: state the illustration variables explicitly in every context.
Component primitives, nothing else borrowed
The interactive parts are built on headless primitives, which handle focus management and keyboard behaviour properly, and none of the default styling survives. One token system in oklch, three typefaces that the footer of every page names out loud (Besley, Archivo and IBM Plex Mono), and small utilities that encode a brand decision once:
/* Condensed caps for column headers, kickers and tags. */
@utility label-caps {
font-family: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;
font-stretch: 75%;
font-weight: 600;
font-size: 11px;
line-height: 16px;
}
A variable font's font-stretch is doing work there that a separate condensed font file would otherwise cost a request for. And having it as one utility means every column header, kicker and tag in the product is the same thing rather than nine near-identical declarations.
What I would tell someone starting an illustration set
- Fix the grid and the stroke width before you draw the second thing.
- Allow three colours, as variables, and let the context set them. No colour props.
- Shade with hatching stroked in the line colour, so shading inverts for free.
- Decorative by default, described on purpose.
- Sanitise generated ids before putting them in
url(#...). - Keep a one-line console check that proves rule 2 still holds.
Go and run that setProperty line on nakodo.app and watch the kingfisher change colour. The pricing page is the same system applied to plan cards, and the privacy page is what it looks like with no illustrations at all, which is the other test: a brand that only works when it is being decorated is not a brand, it is a mood board.
Top comments (0)