A design system in three tiers
114 primitives feed 97 semantic tokens, and the semantic tier feeds everything you can see. Nothing on this page owns a colour: switching theme flips one attribute on <html> and the rest follows.
The type ramp, the radii and the neutral colours were measured from the public stylesheet of a real money-transfer site, as a study. The brand colour, the dark mode, four derived colours and the display typeface substitution are my interpretation, and the product on the other two routes is fictional.
Colour
Every semantic token is an alias onto a primitive, never a raw value. Where light and dark point at the same primitive, the token never enters the dark block of the CSS — which is how a brand surface stays put while the page around it turns.
surface — 12 tokens, 9 themed
Backgrounds. `fixed-dark` is the only one that never themes — it is identity, not mode.
text — 11 tokens, 6 themed
Text colours, including the ones that live on surfaces which never invert.
border — 7 tokens, 5 themed
`default` clears 3:1 in both modes; `subtle` is decorative and does not have to.
action — 18 tokens, 9 themed
Each button type carries four background states and two foregrounds.
feedback — 12 tokens, 10 themed
bg and fg always come from one family — that is what holds contrast when the mode flips.
Typography
Eleven styles, each bound to a role (type/body, type/page-title) which in turn points at the raw ramp. Move the role and everything using it moves; move the ramp and every role moves.
Space, radius and elevation
Elevation/sm
Shadows carry more weight in dark, where the ground is already black.
Elevation/md
Shadows carry more weight in dark, where the ground is already black.
Elevation/lg
Shadows carry more weight in dark, where the ground is already black.
Icons
Sixteen drawings at 24×24, all painted with currentColor — an icon never disagrees with the text beside it.
Components
These are the same instances the marketing page and the app prototype render. There is no separate mobile set.
Button — 3 types × 2 sizes × icon slot
Input — states
Fee shown before you confirm
Over the $920.00 daily limit.
Corridor closed
Badge, list and timeline
Contrast
Computed at build time from these same tokens, not copied out of a spreadsheet. Text needs 4.5:1, non-text needs 3:1. 15 of 15 pairs clear their minimum in both modes. Disabled text is listed too: it is exempt, and the table says so rather than leaving it out.
| Pair | Minimum | Light | Dark |
|---|---|---|---|
| Body copy text.primary on surface.page | 4.5 | 21 ✓ | 14.94 ✓ |
| Secondary text text.secondary on surface.page | 4.5 | 6.9 ✓ | 8.12 ✓ |
| Link text.link on surface.page | 4.5 | 7.02 ✓ | 5.77 ✓ |
| Text on brand text.on-brand on surface.brand | 4.5 | 15.59 ✓ | 15.59 ✓ |
| Primary button action.primary.fg on action.primary.bg | 4.5 | 15.59 ✓ | 15.59 ✓ |
| Secondary button action.secondary.fg on action.secondary.bg | 4.5 | 21 ✓ | 21 ✓ |
| Badge · info feedback.info.fg on feedback.info.bg | 4.5 | 5.25 ✓ | 5.25 ✓ |
| Badge · success feedback.success.fg on feedback.success.bg | 4.5 | 7.55 ✓ | 7.55 ✓ |
| Badge · error feedback.error.fg on feedback.error.bg | 4.5 | 5.64 ✓ | 10.51 ✓ |
| Badge · warning feedback.warning.fg on feedback.warning.bg | 4.5 | 5.53 ✓ | 5.53 ✓ |
| Footer heading text.on-fixed-dark on surface.fixed-dark | 4.5 | 21 ✓ | 21 ✓ |
| Footer link text.on-fixed-dark-secondary on surface.fixed-dark | 4.5 | 7.37 ✓ | 7.37 ✓ |
| Control border border.default on surface.page | 3 | 4.54 ✓ | 5.24 ✓ |
| Focus ring border.focus on surface.raised | 3 | 3.47 ✓ | 4.01 ✓ |
| Outline on brand border.on-brand on surface.brand-bar | 3 | 11.63 ✓ | 11.63 ✓ |
| Disabled control text text.disabled on surface.page · exempt under WCAG 1.4.3, disabled fields only | 4.5 | 2.85 exempt | 2.17 exempt |
Motion
Durations and curves are variables too — TIMING and EASING types, which almost nobody uses. The app prototype drives its screen transitions from these.
instant
100ms
fast
150ms
base
250ms
slow
400ms
enter
450ms
toggle
200ms
gentle
800ms
motion/ease/standard — cubic-bezier(0.2, 0, 0, 1)
motion/ease/enter — cubic-bezier(0, 0, 0, 1)
motion/ease/exit — cubic-bezier(0.3, 0, 1, 1)
motion/ease/gentle — cubic-bezier(0.22, 1, 0.36, 1)