Colour tokens
Five ramps of eleven steps, generated from one hue each; sixty-nine semantic roles built on top of them; and a handful of controls that derive every interactive state. Nothing here is hand-picked, and every value on this page is read out of the browser at runtime rather than typed into the document.
How colour is generated
No swatch in this system was chosen by eye. A ramp is three things multiplied together: one hue angle, a lightness ladder shared by all five ramps, and a chroma ladder of its own. Every step is the same declaration with different generator tokens substituted in.
--color-brand-600: oklch(var(--l-600) var(--c-brand-600) var(--hue-brand));That is the actual line from src/tokens.css, not a paraphrase of it. The
consequence is that a ramp has exactly one editable input for hue: change
--hue-brand and all eleven steps move together, in step, without any of them
leaving sRGB. The chroma ladders peak near the middle of each ramp and fall off at both
ends, which is what keeps --color-brand-50 reading as a tint rather than a
washed-out mid-tone.
| Token | Angle | Step 600 |
|---|---|---|
--hue-brand | | |
--hue-neutral | | |
--hue-success | | |
--hue-warn | | |
--hue-danger | |
The lightness ladder
All five ramps walk the same ladder: even 8% steps from 98% down to 18%. That is what makes
step numbers mean something across ramps: --color-brand-600 and
--color-danger-600 have identical lightness, so swapping one family for another
in a component changes hue and nothing else.
| Token | Value | Neutral |
|---|---|---|
--l-50 | | |
--l-100 | | |
--l-200 | | |
--l-300 | | |
--l-400 | | |
--l-500 | | |
--l-600 | | |
--l-700 | | |
--l-800 | | |
--l-900 | | |
--l-950 | |
An even ladder is perceptually uniform, which is the point. It is also the source of the
two most awkward decisions in the file. Even 8% steps put adjacent-but-one steps only about
2.2:1 apart, never 3:1. So a border role cannot simply sit one step off its surface and
expect to be visible enough to satisfy WCAG; see contrast below. And
because there is no step below --color-neutral-950 and
--color-neutral-100 is a real grey rather than a whisper,
--color-surface-sunken is a color-mix() rather than a ramp step,
and so is the light half of --color-bg.
Mixing keeps it derived from the same source; a hand-picked literal would not.
The chroma ladders
The other half of the generator. Each ramp carries its own chroma ladder, peaking near the middle of the lightness ladder and falling away at both ends so the lightest and darkest steps stay inside sRGB and read as tints rather than as saturated colour. This is why the five ramps share a lightness but not an intensity: orange has far less headroom at a given lightness than blue does, and forcing them to match would push warn out of gamut.
All 55 chroma values
| Step | --c-brand-* | --c-neutral-* | --c-success-* | --c-warn-* | --c-danger-* |
|---|---|---|---|---|---|
50 | | | | | |
100 | | | | | |
200 | | | | | |
300 | | | | | |
400 | | | | | |
500 | | | | | |
600 | | | | | |
700 | | | | | |
800 | | | | | |
900 | | | | | |
950 | | | | | |
The five ramps
Each cell below is filled with its own step token (the fourth cell of the danger row is
literally background-color: var(--color-danger-300)), so this is a live
rendering of the tokens rather than a picture of them. Each cell carries
Aa in --color-white and
Aa in --color-black, which puts the point where
text has to flip colour on the screen instead of in a spreadsheet.
Brand
The product hue. Blue-violet at 262, harmonised with neutral so greys sit in the same family rather than reading as a second colour. Hue token: --hue-brand = .
Neutral
Chroma near zero across the whole ramp, but not exactly zero: the greys carry a trace of the brand hue so surfaces and text never look muddy beside brand-coloured elements. Hue token: --hue-neutral = .
Success
Green at 150. Chroma peaks lower than brand because green is already the most luminous hue at a given lightness. Hue token: --hue-success = .
Warn
Orange at 48. Light at every usable step, which is why solid warn carries a dark label in both themes and why its state derivation differs. Hue token: --hue-warn = . Under data-brand="orange", and only there, it is 80 (amber) with six chroma steps of its own, so warn stays distinct from an orange brand.
Danger
Red-orange at 27. Chroma ladder is the widest of the five, which is what keeps a destructive action separable from brand at the same step. Hue token: --hue-danger = .
Semantic roles
Reference semantic roles, never raw ramp steps. A ramp step is absolute: it is the same
colour in light and dark, so a component built on --color-neutral-900 is
unreadable in one of the two themes. The roles below are
light-dark() pairs, which is the entire theming mechanism. Ramp steps exist to
build roles out of, and for one-off diagrams like the ramps above.
Surfaces
Five stacked planes, from the page up to an inverted strip. The first three are a ladder
in both themes: sunken under the page under a surface. A white card measures 1.21:1 against
the page in light and in dark, and the page 1.08 / 1.09 against a sunken well. Light has no
rung above white, so its step is bought by moving the page down: the page is a 55%
mix of --color-neutral-100 into --color-neutral-50 and sunken an
85% mix, the deepest that keeps --color-text-subtle above 4.5:1 on it (4.62).
Note the deliberate asymmetry one rung up:
in light mode --color-surface and --color-surface-raised are the
same white and elevation is carried entirely by shadow, which is how light interfaces
actually read. In dark mode shadow is nearly invisible, so the two differ by a full ladder
step. A component must therefore never rely on raised being a different colour from
surface: use a shadow token alongside it.
--color-bg --color-surface --color-surface-raised --color-surface-sunken --color-surface-inverse --color-bg --color-surface --color-surface-raised --color-surface-sunken --color-surface-inverse | Token | Job |
|---|---|
--color-bg | The page. Everything else sits on it. |
--color-surface | Cards, panels, the masthead. One step off the page. |
--color-surface-raised | Menus, popovers, dialogs. Pair it with a shadow token. |
--color-surface-sunken | Wells, code blocks, table headers. |
--color-surface-inverse | Tooltips and the solid neutral badge. Takes --color-text-inverse. An inverse band (.u-bg-inverse) does not use it: the band is an opposite-theme subtree filled with that theme's --color-bg. |
Text
Text roles are shown as text on the surface they are meant for. A swatch of
--color-text-subtle tells you nothing useful; a line of it at reading size on
--color-surface tells you whether it is readable. Both muted and
subtle sit one step deeper than the obvious choice, because the even ladder puts
--color-neutral-500 at only 3.55:1 on --color-bg.
Payment details were updated successfully.
Account balance
Updated 2 minutes ago
Transfer confirmed
Send
Payment details were updated successfully.
Account balance
Updated 2 minutes ago
Transfer confirmed
Send
Borders
Three roles with three different jobs, rendered here as actual borders on both
--color-bg and --color-surface. They answer to different bars,
see the contrast section.
--color-border-subtle on --color-bg --color-border on --color-bg --color-border-strong on --color-bg --color-border-subtle on --color-surface--color-border on --color-surface--color-border-strong on --color-surface--color-border-subtle on --color-bg --color-border on --color-bg --color-border-strong on --color-bg --color-border-subtle on --color-surface--color-border on --color-surface--color-border-strong on --color-surfaceThe four families
Brand, success, warn and danger each expose the same six roles: the bare name for icons and
rules, -solid with its -on-solid label, -subtle with
-text for tinted callouts, and -border for outlines. Because the
shape is identical, a component can be written once and parameterised by family.
Brand alone carries a seventh: --color-brand-subtle-raised, the raised-ground
sibling of --color-brand-subtle, the same pairing as
--color-surface /
--color-surface-raised. In dark mode --color-brand-subtle and
--color-surface land on the same rung of the lightness ladder, so a fill built
from one measures 1.00:1 against a ground built from the other: scrollspy.css's
current-link fill shipped exactly that bug. The raised role takes --color-brand-text
the same way --color-brand-subtle does.
Warn is the exception, and deliberately so. Orange is light at every usable step, so
--color-warn-solid is --color-warn-400 in both themes rather than a
light-dark() pair, and --color-warn-on-solid is always dark. Orange
is spent only where it is bright: --color-warn-text is the normal reading colour
in both themes, and the orange itself lives in the icon, the 1px border, the light-mode pale
fill and --color-warn graphics, never in a paragraph of running text.
Brand
--color-brand-solid --color-brand-on-solid --color-brand-subtle --color-brand-text --color-brand-subtle-raised --color-brand-text --color-brand-border Success
--color-success-solid --color-success-on-solid --color-success-subtle --color-success-text --color-success-border Warn
--color-warn-solid --color-warn-on-solid --color-warn-subtle --color-warn-text --color-warn-border Danger
--color-danger-solid --color-danger-on-solid --color-danger-subtle --color-danger-text --color-danger-border Brand
--color-brand-solid --color-brand-on-solid --color-brand-subtle --color-brand-text --color-brand-subtle-raised --color-brand-text --color-brand-border Success
--color-success-solid --color-success-on-solid --color-success-subtle --color-success-text --color-success-border Warn
--color-warn-solid --color-warn-on-solid --color-warn-subtle --color-warn-text --color-warn-border Danger
--color-danger-solid --color-danger-on-solid --color-danger-subtle --color-danger-text --color-danger-border --color-brand-subtle-raised vs --color-surface: 1.34:1 light,
1.31:1 dark, up from 1.00:1 for plain --color-brand-subtle in dark. Light is
unchanged from the plain role, since --color-surface-raised is the same white
as --color-surface there and nothing needed fixing. Dark reaches past
--color-brand-950 rather than up to --color-brand-800, because the
existing --state-hover-amount press mix shades toward white in dark mode: a
fill sitting closer to --color-brand-text would have its label pushed under AA
by that same press. --color-brand-text on the new fill measures 6.37:1 light /
8.92:1 dark at rest, and 4.74:1 / 7.91:1 after the press mix, both themes still clear AA.
Highlight, selection, focus and overlay
| Token | Swatch | Job |
|---|---|---|
--color-highlight-bg | Background of <mark>. Borrows the warn hue because that is what a highlighter looks like, but it is its own role so the two can diverge. | |
--color-highlight-text | Text inside <mark>. | |
--color-selection-bg | The ::selection fill that base.css reads. --color-brand-subtle by default. It is its own role because under orange and mono the dark --color-brand-subtle is the plain surface, which would select text invisibly on every card and dialog; those two presets re-point it in dark to 22% of --color-text over transparent, which shows on whatever ground the selection sits on. | |
--color-focus | The focus ring colour. Aliased to --color-brand rather than restated, so the two cannot drift apart. | |
--color-overlay | The scrim behind a modal. A color-mix() against transparent, so it dims whatever is under it. |
The neutral role family
Neutral carries the same five role names as brand, success, warn and danger, so a component
can write --color-<family>-subtle generically instead of special-casing one
of them. These are aliases onto roles that already exist, no new colours, but they were added
after the first components were written, because without them every component reinvented the
mapping and two of them picked different answers.
Account and notification settings
Send
Account and notification settings
Send
| Role | Aliases | Resolves to |
|---|---|---|
--color-neutral | --color-text-muted | |
--color-neutral-solid | --color-surface-inverse | |
--color-neutral-on-solid | --color-text-inverse | |
--color-neutral-subtle | --color-surface-sunken | |
--color-neutral-text | --color-text-muted | |
--color-neutral-border | --color-border-subtle | |
--color-neutral-solid-hover | derived | |
--color-neutral-solid-active | derived | |
Raised and sunken surface states
--color-surface-hover and --color-surface-active derive from
--color-surface only. Raised and sunken surfaces need their own derivations, and
this is not cosmetic: in dark mode --color-surface-raised sits a step
lighter than --color-surface, so an elevated card pointed at
--color-surface-hover goes darker on hover than it is at rest,
backwards. Deriving from each surface's own resting colour keeps the direction right in both
themes.
| Token | Light | Dark |
|---|---|---|
--color-surface-raised |
|
|
--color-surface-raised-hover |
|
|
--color-surface-raised-active |
|
|
--color-surface-sunken-hover |
|
|
--color-surface-sunken-active |
|
|
A component with several surface variants can do better than picking one of these: derive
from its own local instead, and every variant self-corrects.
card.css does exactly this, which is why an elevated, outlined and subtle card
all hover in the right direction from one declaration.
--card-bg-hover: color-mix(in oklab, var(--card-bg), var(--state-shade) var(--state-hover-amount));Interactive states
How a hover is derived
Hover and active colours are not authored. Each one is a mix between the base fill and a shade token, by a fixed amount:
--color-brand-solid-hover: color-mix(in oklab, var(--color-brand-solid), var(--state-shade) var(--state-hover-amount));--state-shade is black in light and white in dark, so the same formula
darkens the fill in light mode and lightens it in dark mode. The light half is not
--color-black: black has no hue, and neither does a white surface, so the mix of
the two was a true grey beside neutrals that are all cool. It is generated instead: black's lightness, the neutral hue, and the chroma that makes a hover
from white land exactly on --color-neutral-100. Lightness is untouched, so a
solid fill's hover and press are as long as they were with plain black. That is not a cosmetic
preference. It is the rule that keeps the label readable:
A fill hovers by moving away from its own label colour. The label gets more readable under the cursor, never less.
Which means direction is a property of the fill's label, not of the theme.
--state-lift exists for exactly one case: warn, whose fill carries a dark label
in both themes. Using --state-shade there walked the warn label from 5.71:1 at
rest down to 3.55:1 on :active in light mode: the formula was right and the
direction was wrong.
Rest, hover, active
All four families, in both themes. Each cell is filled with its own token and labelled with
its own -on-solid colour, so what you are reading is the actual pairing.
State controls
| Token | Value | Does |
|---|---|---|
--state-shade | | Mixed into a fill to move it away from a light label. Black given the neutral hue in light, white in dark, so it darkens in light and lightens in dark. |
--state-lift | | Always lightens. Used only by warn, whose fill carries a dark label in both themes. |
--state-hover-amount | | How much of the shade or lift is mixed in on :hover. |
--state-active-amount | | The same, on :active. Larger, so the press reads as a further step in the same direction. |
--state-selected-amount | | How much --color-brand is mixed into a surface to mark it selected. |
--state-disabled-opacity | | The veil, for the parts with no fill or edge of their own to change: a quiet or link button, a switch, a choice wrapper, the file picker's button, a tab, a menu item, a collapse trigger, a scrollspy link, a loading label. Every other disabled button and every disabled text control is said in ink instead: the sunken fill, --color-text-subtle and --color-border-subtle. |
Surface states
Neutral surfaces derive the same way. --color-surface-selected mixes in
--color-brand instead of the shade, because selection is a statement about
identity rather than about pressure. The orange preset is the exception in dark
mode: a 12% mix of bright orange into a dark surface is a warm grey, so there it starts from
--color-surface-raised (a checked menu row sits on a raised menu) and mixes
toward --color-text instead.
--color-surface --color-surface-hover --color-surface-active --color-surface-selected --color-surface --color-surface-hover --color-surface-active --color-surface-selected Contrast
The numbers below are measured, not estimated. Each token was resolved in a live browser, converted OKLab → linear sRGB → WCAG relative luminance, and the ratio computed from that. Re-running the measurement is the only way to know these still hold.
Solid fills against their own labels
Read each cell as [rest, hover, active].
| Family | Light | Dark |
|---|---|---|
| brand | 5.05, 6.36, 7.65 | 6.61, 7.42, 8.12 |
| success | 6.20, 7.65, 9.04 | 5.83, 6.66, 7.39 |
| warn | 5.71, 6.55, 7.29 | 5.71, 6.55, 7.29 |
| danger | 6.05, 7.49, 8.88 | 5.90, 6.72, 7.45 |
Every pairing clears AA at 4.5:1, with 5.05:1 the worst case. More usefully, every one improves on hover and improves again on active, which is the derivation rule doing its job, in twenty-four measurements, rather than a coincidence.
Text and border roles
| Pairing | Light | Dark |
|---|---|---|
--color-text on --color-bg | 14.66 | 13.95 |
--color-text-muted on --color-bg | 7.91 | 8.15 |
--color-text-subtle on --color-surface | 5.91 | 6.08 |
--color-border on --color-bg | 4.00 | 4.40 |
--color-border on --color-surface | 4.22 | 3.63 |
--color-border-strong on surface / bg | 5.61 | 5.00 |
The three border roles answer to different bars. --color-border is what
delimits a form control, so WCAG 2.2 SC 1.4.11 applies and it must clear 3:1 against every
surface it can land on. Adjacent-but-one ladder steps are only about 2.2:1 apart, so it
cannot simply sit one step off the surface, which is why it is
--color-neutral-500 in both themes rather than a
light-dark() pair. L58% is the one step that clears 3:1 against near-white and
near-black alike. --color-border-subtle is decorative (rules, gridlines,
the divider inside a box, a disabled control's edge) at roughly 2.2:1, and is exempt from
that requirement. A container's own edge (card, accordion, menu, fieldset) is
--color-border.
Retuning a hue does not re-check any of this. Chroma and lightness are held constant by
the generators, but relative luminance is hue-dependent, so every ratio on this page can
move when --hue-brand, --hue-success,
--hue-warn or --hue-danger changes. Re-measure after any hue
change, and re-measure the state triplets too: they are mixes of the thing you moved.
Retuning a ramp
One declaration in your own stylesheet is the whole operation. Your CSS is unlayered, so it
wins over the tokens layer without any priority-raising declaration.
:root {
--hue-brand: 275;
}That regenerates all eleven ramp steps from --color-brand-50 to
--color-brand-950, both
light-dark() arms of --color-brand,
--color-brand-solid, --color-brand-subtle,
--color-brand-text and --color-brand-border, both derived state
colours, --color-surface-selected (which mixes brand in), and
--color-focus (which is an alias of brand). Retuning
--hue-neutral additionally moves every surface, every text role, every border
role and both shadow tints, since those are mixed from the neutral ramp rather than from
literals.
Re-measure the contrast tables above. A hue change is a one-line edit with a system-wide blast radius, and nothing in plain CSS will tell you it broke AA.
Brand presets
Seven ready-made main colours ship as data-brand presets: one attribute on the
root element instead of hand-picking a hue. blue is the shipped default.
See Choosing the main colour for the mechanism, including
why it is root-only and why mono flips with the theme rather than shipping as a
literal black and a literal white, and
A custom main colour, from a seed to
build one of your own from a hex or rgb value.
mono · black & white
red
hue
orange · step 400
hue
green
hue
teal
hue
blue · default
hue
violet
hue
[data-brand] is root-only, so a swatch cannot simply read
--color-brand-600 and expect it to follow the wrapper: that step is
declared at :root, its var()s were substituted there,
and what reaches the wrapper is the finished colour rather than the recipe.
What the wrapper does put in scope is the ramp's inputs:
--hue-brand, and the --c-brand-* ladder for the presets that
re-point it. So each fill above re-runs the ramp formula here, on the swatch itself,
rather than restating a colour:
background-color: oklch(var(--l-600) var(--c-brand-600) var(--hue-brand));That is the same expression tokens.css uses to build the step, so these
swatches track the real ramp, including the presets that borrow a narrower chroma ladder
because their hue does not fit the brand one. Nothing here is a hand-written colour, and
there is no second copy of the numbers to fall out of date. Two swatches differ. The
orange swatch re-runs the formula at step 400, not 600, because 400 is its
solid fill in both themes (with a near-black label): orange turns brown as it darkens, so
its 600 is a colour no role uses. And
mono is not a hue and has no ramp to re-run, so it reads
--color-brand-solid, which that preset re-points directly and which
therefore does resolve in the wrapper, so that swatch also flips with the theme,
which is the preset behaving correctly rather than the strip going wrong.