Colour
Ten families of eleven stops, a twelfth in the two surface ladders, 54 roles, and a rule that keeps them apart: nothing in the product references a primitive directly.
How colour is organised
Three layers, and a seam between the first two. Primitives name a value — twilight/600 — and are never applied in a component. Theme tokens name a job — interactive/accent — and point at a primitive; they are the only layer a component references, and the only one that changes between light and dark. That seam is what makes the dark theme possible: without it, dark mode would mean auditing every use to work out which meant “the accent” and which just wanted violet.
Primitives
The raw ramps. These carry no meaning and are not used in components — they exist to be aliased. Every family is generated in OKLCH and shares one lightness per stop, so a number means the same amount of light everywhere: any 600 carries a white label at 4.5:1 or better, any 400 carries a night/950 label, any 500 is the 3:1 stop for icons and borders, any 700 is text on its own 050, any 300 is text on its own 900. Chroma and hue are each family’s own.
There are no half steps for text or fills. The twenty-step neutral this replaced had adjacent steps 1.08 to 1.23:1 apart, and produced two text levels nobody could tell apart. The one exception is 925, the surface step, in stone and night only: no text is ever set in one surface against another, and every reference system measured places its surface levels closer than any ramp places its text stops — see Elevation and states. When a ladder runs out, separate with a border.
glow the brand — hero, gradient, one call to action per screen; never a button tone
twilight everything interactive — fills, links, focus, selection
flare the highlight fill — the tertiary tone
glacier the second highlight, and the info status
stone the neutral foundation — text, borders, the light canvas
night the dark surface ladder
mist soft states — the wash that hover and pressed lay over any surface, in both modes
ember danger
moss success
amber warning
One lightness per stop
The lightness is stone’s, read in OKLCH; every other family sits within a thousandth of it. Each stop’s job is measured across all ten families as the page renders, and the family shown is the one where the pair is tightest.
| Stop | L | Carries | Tightest |
|---|---|---|---|
| 050 | 0.975 | — | |
| 100 | 0.945 | — | |
| 200 | 0.896 | — | |
| 300 | 0.824 | text on its own 900 | glow 9.36AAA |
| 400 | 0.730 | a night/950 label | glow 7.51AAA |
| 500 | 0.625 | icons and borders on white, 3:1 | moss 3.353:1 |
| 600 | 0.525 | a white label | moss 5.09AA |
| 700 | 0.430 | text on its own 050 | moss 7.25AAA |
| 800 | 0.329 | — | |
| 900 | 0.244 | — | |
| 950 | 0.159 | — |
Choosing a colour
Reach for the role, never the value. A component that needs the accent takes interactive/accent; if what it needs has no token, the answer is a new token, not a primitive that happens to look right today — a separator borrowed as a text colour reads until borders get lighter, and then the text goes with them.
One colour, one meaning. The accent means interactive, so that hue on static text tells the reader to click something that is not a control. glow is the brand and never a button tone: glow/600 and ember/600 are 1.02:1 apart, and a pink button beside a danger button would be two of the same thing. amber is warning and flare the highlight, kept far enough apart at 400 to read as two. And filled colour is emphasis: one solid accent per view, with the alternative beside it neutral — see Button.
Measure the pair that renders. Text against the surface it sits on, a border against the fill it edges, a label against its own button in every state, and an alpha over the ground it lands on. The tables below do exactly that, with the function the test suite runs; a value written by hand is a value that can rot.
Surfaces
A ladder of elevation rather than a set of colours. In light, raised and overlay are both white and the shadow does the separating; in dark, overlay steps lighter because shadow no longer reads as height, and sunken shares the canvas because the ramp ends at 950 — a well on the canvas takes a border. The dark ladder is night 950, 925, 900, ΔL .043 per step; a product that wants a neutral dark aliases the same stops of stone, and every pair holds — the tightest, border/strong on stone/800, is 3.44:1. The tail is deep on purpose: 700 to 950 sit at L .43, .33, .245, .205 and .16, so the dark canvas reads as night rather than slate. Alpha tokens are shown flattened over a card.
| Token | Light | Dark | ||
|---|---|---|---|---|
base App canvas | stone/050 | night/950 | ||
raised Cards, panels, table body | white | night/925 | ||
overlay Modals, popovers, dropdowns | white | night/900 | ||
sunken Read-only fields, checkbox and radio box, neutral badge, avatar overflow | stone/100 | night/950 | ||
scrim Modal backdrop | alpha/mist-95 | alpha/ink-95 | ||
inverse Avatar fill | stone/950 | stone/050 | ||
accent-subtle Selected nav, highlighted row | twilight/050 | twilight/900 | ||
success-subtle Success badge | moss/050 | moss/900 | ||
warning-subtle Warning badge | amber/050 | amber/900 | ||
danger-subtle Error badge | ember/050 | ember/900 | ||
info-subtle Info badge | glacier/050 | glacier/900 | ||
Text
Measured against surface/raised. Placeholder is the same stop as tertiary and clears AA; disabled and inert sit below it deliberately — both are exempt under WCAG 2.1, and both are documented at their recorded value so a future edit cannot quietly make them worse.
| Token | Light | Dark | ||
|---|---|---|---|---|
primary Headings and body | stone/900 16.28AAA | stone/050 16.83AAA | ||
secondary Labels, metadata | stone/800 12.28AAA | stone/300 10.51AAA | ||
tertiary Helper text, timestamps | stone/600 5.38AA | stone/400 7.55AAA | ||
placeholder Input placeholders | stone/600 5.38AA | stone/400 7.55AAA | ||
disabled Disabled text (WCAG-exempt) | stone/400 2.39Fail | stone/600 3.363:1 | ||
inert Inert text beside interactive content, such as days outside the month (WCAG-exempt) | stone/300 1.72Fail | night/700 2.19Fail | ||
inverse Text on surface/inverse | white 1.00Fail | stone/950 1.08Fail | ||
accent Links | twilight/600 5.96AA | twilight/300 10.24AAA | ||
success Success messages | moss/700 7.73AAA | moss/300 10.92AAA | ||
warning Warning messages | amber/800 12.25AAA | amber/300 10.55AAA | ||
danger Validation errors | ember/700 8.86AAA | ember/300 10.16AAA | ||
info Info messages | glacier/700 7.85AAA | glacier/300 10.90AAA | ||
Interactive
Fills and the labels that sit on them. Every on-* token is measured against all of its fill states, not just the resting one. The two wash tokens are a state layer rather than a fill — laid over a row, a menu item, a ghost button or the neutral button’s own fill — and are shown here over a card; their readings are on the Elevation page.
| Token | Light | Dark | ||
|---|---|---|---|---|
accent Primary button fill | twilight/600 5.96AA | twilight/400 7.18AAA | ||
accent-hover Primary hover | twilight/700 8.89AAA | twilight/300 10.24AAA | ||
accent-pressed Primary pressed | twilight/800 13.19AAA | twilight/200 12.99AAA | ||
on-accent Label on accent | white 1.00Fail | night/950 1.08Fail | ||
neutral Secondary button fill | stone/050 1.07Fail | stone/800 1.47Fail | ||
on-neutral Label on neutral | stone/900 16.28AAA | stone/050 16.83AAA | ||
wash-hover Hover wash over any surface or the neutral fill: rows, menu items, ghost, outline and neutral buttons, icon buttons | alpha/haze-08 | alpha/haze-12 | ||
wash-pressed Pressed wash, same consumers | alpha/haze-16 | alpha/haze-20 | ||
tertiary Highlight fill | flare/400 2.53Fail | flare/400 7.13AAA | ||
tertiary-hover Highlight hover | flare/300 1.76Fail | flare/300 10.24AAA | ||
tertiary-pressed Highlight pressed | flare/200 1.39Fail | flare/200 13.00AAA | ||
on-tertiary Label on highlight | stone/900 16.28AAA | night/950 1.08Fail | ||
success Confirming button fill | moss/600 5.09AA | moss/400 7.96AAA | ||
success-hover Confirming hover | moss/700 7.73AAA | moss/300 10.92AAA | ||
success-pressed Confirming pressed | moss/800 11.82AAA | moss/200 13.49AAA | ||
on-success Label on success | white 1.00Fail | night/950 1.08Fail | ||
danger Destructive button fill | ember/600 5.93AA | ember/400 7.04AAA | ||
danger-hover Destructive hover | ember/700 8.86AAA | ember/300 10.16AAA | ||
danger-pressed Destructive pressed | ember/800 12.97AAA | ember/200 12.96AAA | ||
on-danger Label on danger | white 1.00Fail | night/950 1.08Fail | ||
selected Selected row, tab, nav | twilight/050 1.08Fail | twilight/900 1.06Fail | ||
disabled Disabled fill — in Dark equals neutral; the label carries the state | stone/200 1.36Fail | stone/800 1.47Fail | ||
on-disabled Disabled label | stone/400 2.39Fail | stone/600 3.363:1 | ||
Border
Three tiers by function, not by weight. subtle divides, and is an alpha so it reads on every surface without picking a stop above any of them; default outlines containers; and strong is the only tier that clears WCAG 1.4.11 — which is why every form control uses it.
| Token | Light | Dark | ||
|---|---|---|---|---|
subtle Dividers, row separators | alpha/ink-08 1.18Fail | alpha/white-16 1.62Fail | ||
default Cards and containers — decorative | stone/300 1.72Fail | night/600 3.303:1 | ||
strong All form control boundaries | stone/500 3.573:1 | stone/500 5.06AA | ||
accent Active, selected | twilight/600 5.96AA | twilight/400 7.18AAA | ||
focus Focus ring — the only focus token | twilight/500 3.923:1 | twilight/300 10.24AAA | ||
danger Error | ember/600 5.93AA | ember/400 7.04AAA | ||
success Validated | moss/600 5.09AA | moss/400 7.96AAA | ||
inverse On surface/inverse | stone/700 8.10AAA | stone/200 13.24AAA | ||
Accessibility
Colour is never the only channel. A state has an icon, a label or a shape beside its colour; focus adds a ring rather than recolouring a border; a selected row carries an attribute a screen reader can read. Text clears 4.5:1 and non-text, borders and icons and the parts of a control, 3:1, on the surface they render on, in both modes.
Two text tokens sit under the line on purpose. disabled and inert are exempt under WCAG 2.1 — one is an unavailable control, the other decoration — and each is documented at its recorded value above, so an edit that made either worse would show in the table before it shipped.