@arag 0.1.0

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.

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

50 AaAa
100 AaAa
200 AaAa
300 AaAa
400 AaAa
500 AaAa
600 AaAa
700 AaAa
800 AaAa
900 AaAa
950 AaAa

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 AaAa
100 AaAa
200 AaAa
300 AaAa
400 AaAa
500 AaAa
600 AaAa
700 AaAa
800 AaAa
900 AaAa
950 AaAa

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 AaAa
100 AaAa
200 AaAa
300 AaAa
400 AaAa
500 AaAa
600 AaAa
700 AaAa
800 AaAa
900 AaAa
950 AaAa

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.

50 AaAa
100 AaAa
200 AaAa
300 AaAa
400 AaAa
500 AaAa
600 AaAa
700 AaAa
800 AaAa
900 AaAa
950 AaAa

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 AaAa
100 AaAa
200 AaAa
300 AaAa
400 AaAa
500 AaAa
600 AaAa
700 AaAa
800 AaAa
900 AaAa
950 AaAa

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

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

Light

Payment details were updated successfully.

--color-text

Account balance

--color-text-muted

Updated 2 minutes ago

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

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

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, selection, 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-selection-bgThe ::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-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

Account and notification settings

Send

Dark

Account and notification settings

Send

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

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

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. Black given the neutral hue in light, white in dark, 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-opacityThe 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.

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.71, 6.55, 7.295.71, 6.55, 7.29
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.916.08
--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, 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.

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.

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

How these fills are built

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