@arag 0.1.0

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.

The five hue angles. Every colour in the system descends from one of these.
TokenAngleStep 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.

The shared lightness ladder, shown against the neutral ramp.
TokenValueNeutral
--l-50
--l-100
--l-200
--l-300
--l-400
--l-500
--l-600
--l-700
--l-800
--l-900
--l-950
The trade-off

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
Chroma per ramp, per step. Internal generators — components never read these directly, they read the composed --color-* tokens.
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 = .

50 AaАа
100 AaАа
200 AaАа
300 AaАа
400 AaАа
500 AaАа
600 AaАа
700 AaАа
800 AaАа
900 AaАа
950 AaАа

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

50 AaАа
100 AaАа
200 AaАа
300 AaАа
400 AaАа
500 AaАа
600 AaАа
700 AaАа
800 AaАа
900 AaАа
950 AaАа

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

50 AaАа
100 AaАа
200 AaАа
300 AaАа
400 AaАа
500 AaАа
600 AaАа
700 AaАа
800 AaАа
900 AaАа
950 AaАа

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

50 AaАа
100 AaАа
200 AaАа
300 AaАа
400 AaАа
500 AaАа
600 AaАа
700 AaАа
800 AaАа
900 AaАа
950 AaАа

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

50 AaАа
100 AaАа
200 AaАа
300 AaАа
400 AaАа
500 AaАа
600 AaАа
700 AaАа
800 AaАа
900 AaАа
950 AaАа

Semantic roles

Rule

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.

Light
--color-bg
--color-surface
--color-surface-raised
--color-surface-sunken
--color-surface-inverse
Dark
--color-bg
--color-surface
--color-surface-raised
--color-surface-sunken
--color-surface-inverse
TokenJob
--color-bgThe page. Everything else sits on it.
--color-surfaceCards, panels, the masthead. One step off the page.
--color-surface-raisedMenus, popovers, dialogs. Pair it with a shadow token.
--color-surface-sunkenWells, code blocks, table headers.
--color-surface-inverseTooltips 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.

Light

Payment details were updated successfully. Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.

--color-text

Account balance Дансны үлдэгдэл

--color-text-muted

Updated 2 minutes ago 2 минутын өмнө шинэчлэгдсэн

--color-text-subtle

Transfer confirmed Гуйвуулга баталгаажлаа

--color-text-inverse on --color-surface-inverse

Send Илгээх

--color-text-on-solid on --color-brand-solid

Dark

Payment details were updated successfully. Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.

--color-text

Account balance Дансны үлдэгдэл

--color-text-muted

Updated 2 minutes ago 2 минутын өмнө шинэчлэгдсэн

--color-text-subtle

Transfer confirmed Гуйвуулга баталгаажлаа

--color-text-inverse on --color-surface-inverse

Send Илгээх

--color-text-on-solid on --color-brand-solid

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.

Light
--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
Dark
--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

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

Light

Brand

--color-brand — the bare role, for icons and rules

--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 — the bare role, for icons and rules

--color-success-solid --color-success-on-solid
--color-success-subtle --color-success-text
--color-success-border

Warn

--color-warn — the bare role, for icons and rules

--color-warn-solid --color-warn-on-solid
--color-warn-subtle --color-warn-text
--color-warn-border

Danger

--color-danger — the bare role, for icons and rules

--color-danger-solid --color-danger-on-solid
--color-danger-subtle --color-danger-text
--color-danger-border
Dark

Brand

--color-brand — the bare role, for icons and rules

--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 — the bare role, for icons and rules

--color-success-solid --color-success-on-solid
--color-success-subtle --color-success-text
--color-success-border

Warn

--color-warn — the bare role, for icons and rules

--color-warn-solid --color-warn-on-solid
--color-warn-subtle --color-warn-text
--color-warn-border

Danger

--color-danger — the bare role, for icons and rules

--color-danger-solid --color-danger-on-solid
--color-danger-subtle --color-danger-text
--color-danger-border
Measured

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

TokenSwatchJob
--color-highlight-bgBackground 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-textText inside <mark>.
--color-focusThe focus ring colour. Aliased to --color-brand rather than restated, so the two cannot drift apart.
--color-overlayThe 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.

Light

Хэрэглэгчийн тохиргоо

Илгээх

Dark

Хэрэглэгчийн тохиргоо

Илгээх

Neutral roles, and the role each one aliases.
RoleAliasesResolves 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-hoverderived
--color-neutral-solid-activederived

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.

Resolved per theme. Compare the rest value with its hover in each column.
TokenLightDark
--color-surface-raised
--color-surface-raised-hover
--color-surface-raised-active
--color-surface-sunken-hover
--color-surface-sunken-active
Or derive in the component

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:

The rule

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.

Light

Brand

rest
hover
active

Success

rest
hover
active

Warn

rest
hover
active

Danger

rest
hover
active
Dark

Brand

rest
hover
active

Success

rest
hover
active

Warn

rest
hover
active

Danger

rest
hover
active

State controls

TokenValueDoes
--state-shadeMixed 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-liftAlways lightens. Used only by warn, whose fill carries a dark label in both themes.
--state-hover-amountHow much of the shade or lift is mixed in on :hover.
--state-active-amountThe same, on :active. Larger, so the press reads as a further step in the same direction.
--state-selected-amountHow much --color-brand is mixed into a surface to mark it selected.
--state-disabled-opacityApplied 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.

Light
--color-surface
--color-surface-hover
--color-surface-active
--color-surface-selected
Dark
--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].

Solid fill vs its own -on-solid label.
FamilyLightDark
brand5.05, 6.36, 7.656.61, 7.42, 8.12
success6.20, 7.65, 9.045.83, 6.66, 7.39
warn5.70, 6.52, 7.245.70, 6.52, 7.24
danger6.05, 7.49, 8.885.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

PairingLightDark
--color-text on --color-bg14.6613.95
--color-text-muted on --color-bg7.918.15
--color-text-subtle on --color-surface5.915.00
--color-border on --color-bg4.004.40
--color-border on --color-surface4.223.63
--color-border-strong on surface / bg5.615.00
Why the border numbers differ

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.

Warning

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.

Before you ship a retune

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.