01/09Audit

Design systems · Design engineering

Designing a design system, end to end

Token architecture, colour, typography, motion and a live component library, built on my own portfolio and shipped into production. Every button, input and toggle below is the system running. No screenshots.

Role

Design and engineering, solo

Surface

diegomunar.com

Stack

Next.js, CSS custom properties, Tailwind


Step 01

Count what is actually there

A system does not start with a palette. It starts with an inventory, because you cannot fix drift you have not measured. I wrote a shell script that greps the codebase for every colour, type size and radius, and ran it against my own site.

I had declared eight colour tokens. The script found fourteen colours in use, sixty nine distinct type sizes and nineteen radii. The tokens existed. Nothing referenced them.

Audit output, first run

8colour tokens declared in :root
14distinct colours actually rendered
69distinct type sizes across the site
19distinct border radii
143inline rgba() values, none of them named

A design system nobody references is a style guide. The difference is enforcement, not documentation.

Step 02

Decide the architecture before picking a single colour

The reason my first eight tokens failed is that they were named after what they looked like. --accent tells you nothing about when to use it, so people guess, and guessing produces fourteen colours. Three tiers fixes that.

Token tiers

Hover any tier to follow one value down the chain.

01

Primitive

accent-500 = #FE320C

The raw value. Named after what it is. Nobody builds with these directly.

02

Semantic

action/primary → accent-500

Named after the job. This is the only layer the interface is allowed to call.

03

Component

button/bg/rest → action/primary

Named after the part. Lets one component shift without touching the others.

The payoff is concrete. Changing the pressed state of every button in the product is one line in tier three. Rebranding is one ramp in tier one. Neither touches a component file.

Step 03

Colour: ramps first, decisions second

Four ramps, five stops each. Hover and pressed states are steps on the accent ramp rather than colours someone eyeballed, which is why the interface stays coherent when a new component appears.

Primitives

Hover a swatch for its contrast as text on the page background. The deep stops fail that test on purpose: they are border and fill values, and the ramp says so instead of leaving it to judgement. Click any swatch to copy its token.

Ground — every dark surface in the product

ground-900
#0c0c0b
page

ground-800
#111110

ground-700
#151514
surface

ground-600
#1b1b18
raised

ground-500
#23231f

Ink — every piece of text

ink-100
#F0EDE6
body copy

ink-200
#C9C5BC

ink-300
#9A9890
secondary

ink-400
#6F6C66
tertiary

ink-500
#4A4844

Accent — one hue, five stops, so states are derived and not invented

accent-300
#FF8A6E

accent-400
#FF5C3A
hover

accent-500
#FE320C
base

accent-600
#CC2809
pressed

accent-700
#991E07

Support — status only. Never decoration.

ok-500
#5FB08A
success

info-500
#4A8FD4
information

warn-500
#E0A03A
warning

danger-500
#E5484D
destructive

Semantic layer — the only names components may use

Hover a row to see what it resolves to. Click the token to copy it.

Semantic tokenResolves toWhere it is allowed
ground-900Page background
ground-700Cards, panels, wells
ground-600Menus, popovers, modals
ink-100Headings and body copy
ink-300Supporting copy, captions
ink-400Placeholders, disabled labels
ink-100 @ 8%Dividers, resting inputs
ink-100 @ 16%Hovered inputs, focused cards
accent-500Primary buttons, live links
accent-400Primary hover
accent-600Primary active
accent-500 @ 40%Every focusable element

Support colours carry status and nothing else. The moment green means both success and a brand accent, it stops meaning success.

Step 04

Typography: eleven steps, and a rule for each

Sixty nine sizes became eleven. I did not invent a modular scale, I derived the steps from what the site already used and collapsed neighbours closer than a pixel. A scale taken from the code describes the product that exists. An invented one describes a product you wish you had built.

Each step is fluid, so a single token covers phone through desktop and there are no breakpoint overrides for text anywhere in the codebase.

Type scale, rendered at its real size

Live at px wide. Resize the window and every number below moves, because each step is one fluid token instead of a breakpoint stack. Click a token to copy it.

clamp(4.5rem, 13vw, 11rem)
line-height 0.92

Hero name. Once per page.

Ship it

clamp(3.5rem, 7vw, 6.5rem)
line-height 0.98

Case study title

Ship it

clamp(2.4rem, 5vw, 4rem)
line-height 1.06

Section opener

Ship it

clamp(1.6rem, 3vw, 2.4rem)
line-height 1.15

Subsection heading

Ship it

clamp(1.4rem, 2.5vw, 1.8rem)
line-height 1.25

Card title, pull quote

Ship it

clamp(1rem, 1.6vw, 1.15rem)
line-height 1.65

Lead paragraph

Ship it

clamp(0.95rem, 1.5vw, 1.1rem)
line-height 1.6

Body copy

Ship it

clamp(0.9rem, 1.4vw, 1.05rem)
line-height 1.6

Dense body, table cells

Ship it

clamp(0.85rem, 1.2vw, 0.95rem)
line-height 1.5

Captions, helper text

Ship it

0.8rem
line-height 1.45

Metadata, timestamps

Ship it

0.7rem
line-height 1.4

Eyebrows, tags. Uppercase only.

Ship it

Step 05

Space, radius and motion are tokens too

Most systems stop at colour and type, which is why their products still feel loose. Rhythm, corner language and easing are the three things people read as quality without being able to name them.

Spacing — 4px base unit

Base unit 4px. Every gap, pad and margin in the product is one of these nine values, so vertical rhythm is a choice from a list instead of a number someone typed.

Hover a row to see it as a multiple of the base unit.

Radius

Hover to flatten a tile to a square, which is the only way to actually read a corner.

Motion — press replay

ease-out-expocubic-bezier(0.16, 1, 0.3, 1)

Entrances. Fast out of the gate, long settle.

ease-out-quintcubic-bezier(0.22, 1, 0.36, 1)

Layout shifts. Slightly softer.

ease-in-outcubic-bezier(0.65, 0, 0.35, 1)

Anything that leaves and returns.

dur-fast

200ms

Hover, focus, colour

dur-base

300ms

Toggles, small position changes

dur-slow

600ms

Entrances, reveals

Three curves is a system. Eleven curves is eleven people each picking a favourite.

Step 06

Components: every state, drawn before it is needed

This is where systems are won or lost. A component is not the resting state, it is the full matrix. Hover, pressed, focus, disabled, loading, error. If the designer does not draw them, an engineer invents them at 6pm on a Friday.

Button — four intents by five states

resthoveractivefocusdisabled
primarySaveSaveSaveSaveSave
secondarySaveSaveSaveSaveSave
ghostSaveSaveSaveSaveSave
dangerSaveSaveSaveSaveSave

Button — sizes, live

sm · 30px

md · 38px

lg · 46px

Saving

loading

Hover and press the row above. The transitions use dur-fast and ease-out-expo from step five.

Input — the four states that actually ship

you@company.com

We only use it to reply.

rest

diego@

We only use it to reply.

focus

diego@

Add a domain, like example.com.

error

locked@company.com

Managed by your admin.

disabled

Error is a state of the field, not a red line underneath it. The border, the ring and the helper text all change together, which is why the error is legible without colour alone carrying the message.

Selection controls — live, keyboard operable

Checkbox

Radio

Toggle

Status badges

NeutralLiveShippedReviewBlocked

Step 07

The handoff is the prop signature

A component is only shared if its API is small enough to remember. I write the prop signature at the same time as the Figma variants, and they use the same words, so the conversation between design and engineering stops being a translation.

Anatomy — every measurement is a token

Save changes
Height38px
size-md
Padding0 18px
space-4 + 2
Radius6px
radius-base
Label0.86rem / 500
text-sm
Gap to icon8px
space-2
Focus ring3px @ 40%
focus/ring

Public API

type ButtonProps = {
  intent?:   "primary" | "secondary" | "ghost" | "danger"
  size?:     "sm" | "md" | "lg"
  loading?:  boolean
  disabled?: boolean
}

Playground — change the props, watch the JSX

Save changes

Intent

Size

State

Label

<Button>Save changes</Button>

Variants over booleans. Four intents in one prop cannot contradict each other; four booleans can produce sixteen combinations, twelve of which are nonsense.

Step 08

Keep it alive, or it rots in a quarter

The audit script from step one is the system's immune response. It runs on every commit and prints the same numbers, so drift shows up as a diff instead of as a feeling six months later.

It also separates two surfaces, which took me two wrong runs to get right: system chrome must use tokens, while a case study recreating a client's interface must keep that client's literal colours. Counting those as debt would have pushed me to tokenise someone else's brand into mine.

What goes in the system

Does something like it already exist?

Use it. Extend it with a prop only if two more screens need the same thing.

Will three or more screens use it?

It goes in the system. Fewer than three, it stays local to its feature.

Is the change visual or behavioural?

Visual goes in a token. Behavioural goes in a prop. Never the other way around.

Can it be described without naming a screen?

If not, it is not a system component yet. It is a layout.

Step 09

What changed

BeforeAfter
Token references in the codebase96278
Hardcoded colour values9746
Distinct colours rendered1411
Unnamed inline rgba()14363

The number I care about is the first one. Adoption is the only metric that separates a design system from a folder of opinions.

Every button, input, toggle and badge on this page is the live system, running in production, not an image of one.