Colour tokens
Five ramps of eleven steps, generated from one hue each; fifty-four 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.
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: amber 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
Аа 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. Deep blue at 255, 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
Amber at 75. 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 = .
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. Note the deliberate asymmetry:
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 inverted strips. Takes --color-text-inverse. |
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, in both scripts. Both muted and
subtle sit one step deeper than the obvious choice, because the even ladder puts
--color-neutral-500 at only 4.04:1 on --color-bg.
Payment details were updated successfully. Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.
Account balance Дансны үлдэгдэл
Updated 2 minutes ago 2 минутын өмнө шинэчлэгдсэн
Transfer confirmed Гуйвуулга баталгаажлаа
Send Илгээх
Payment details were updated successfully. Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.
Account balance Дансны үлдэгдэл
Updated 2 minutes ago 2 минутын өмнө шинэчлэгдсэн
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 — 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. Amber is light at every usable step, so
--color-warn-solid is --color-warn-400 in both themes rather than a
light-dark() pair, --color-warn-on-solid is always dark, and
--color-warn-text sits deeper in the ramp than its peers.
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, 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-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.
Хэрэглэгчийн тохиргоо
Илгээх
Хэрэглэгчийн тохиргоо
Илгээх
| 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 light-dark(black, white), so the same formula
darkens the fill in light mode and lightens it in dark mode. 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.70:1 at
rest down to 3.54: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. light-dark(black, white), 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 | | Applied to the whole control, not to individual colours, so text and fill fade together. |
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.
--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.70, 6.52, 7.24 | 5.70, 6.52, 7.24 |
| 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 | 5.00 |
--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,
disclosure outlines — at roughly 2.2:1, and is exempt from that requirement.
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.