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

050100200300400500600700800900950

twilight everything interactive — fills, links, focus, selection

050100200300400500600700800900950

flare the highlight fill — the tertiary tone

050100200300400500600700800900950

glacier the second highlight, and the info status

050100200300400500600700800900950

stone the neutral foundation — text, borders, the light canvas

050100200300400500600700800900925950

night the dark surface ladder

050100200300400500600700800900925950

mist soft states — the wash that hover and pressed lay over any surface, in both modes

050100200300400500600700800900950

ember danger

050100200300400500600700800900950

moss success

050100200300400500600700800900950

amber warning

050100200300400500600700800900950

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.

StopLCarriesTightest
0500.975
1000.945
2000.896
3000.824text on its own 900glow 9.36AAA
4000.730a night/950 labelglow 7.51AAA
5000.625icons and borders on white, 3:1moss 3.353:1
6000.525a white labelmoss 5.09AA
7000.430text on its own 050moss 7.25AAA
8000.329
9000.244
9500.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.

TokenLightDark
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.

TokenLightDark
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.

TokenLightDark
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.

TokenLightDark
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.