Color

The only token category with two values per token, and the only one where the brand deliberately spends nothing.

Every colour is declared in full under both [data-mode="light"] and [data-mode="dark"] — neither is a set of overrides on the other. That costs a little duplication and buys one thing: data-mode can be re-applied to any element to switch just that subtree, so whole-page and section-by-section modes are the same mechanism. The two columns in every table below are that fact, rendered — each cell is a mode subtree, and each swatch resolves inside it.

The values are read from tokens.css itself rather than written here, so the page cannot drift from the source — and what it shows is what is written, not what that resolved to. A role pointing at the ramp reads as --neutral-96; the swatch beside it says what that looks like. Two different questions, and the table answers the one the swatch cannot.

It also makes the tiers visible without labelling them. Most roles render as a --neutral-* step, brand renders as #000000 because it is deliberately a literal, and a film renders as a color-mix() because its transparency is its value. Names are semantic, never value-specific: --color-surface-raised, not --paper — so a value can change without a rename.

Brand

One token, and the decision the rest of the page is built on.

TokenLightDark
--color-brand#000000#ffffff

Pure ink, and it inverts with the mode — black on light, white on dark. The brand is monochrome by choice, which is what frees colour to mean status and nothing else: buttons, links and focus rings stay black or white, so a green or a red on the page is always carrying information rather than decoration.

It is a separate token from --color-text-primary rather than an alias of it. The text ramp is a softened near-black; brand is the true extreme. They are close today and can move independently.

Neutrals

One ramp under every role on this page, and the same ramp in both modes.

Every step is written as oklch(L var(--neutral-chroma) var(--neutral-hue)), so the lightness is fixed per step and the other two coordinates are shared. Both ship at 0, which is what makes the ramp pure grey — and moving them is the entire re-skin surface. Chroma is a small decimal, not a percentage: 0.009 is a real tint and 0.03 is a colour. Hue is degrees on the usual wheel.

The dial stops at 0.009, and that is a decision rather than a limit. Nothing stops chroma running to the edge of the gamut — set it high and the neutral ramp becomes a red one, lightness ladder intact. Two reasons to hold it here, and only the first is measured: 0.009 is the lowest per-hue ceiling on the ramp, so it is the one value that tints evenly at any hue — above it the lightest steps clip while the middle keeps colouring, and a non-uniform tint is worse than a weak one. The second is simply that past a tint these stop being neutrals.

Try it

Two values, and they move every neutral on the site — both modes at once, including the Light and Dark swatches in the tables below.

Presets

TokenValue
--neutral-chroma0
--neutral-hue0
--neutral-0oklch(0.00 var(--neutral-chroma) var(--neutral-hue))#000000
--neutral-15oklch(0.15 var(--neutral-chroma) var(--neutral-hue))#0b0b0b
--neutral-18oklch(0.18 var(--neutral-chroma) var(--neutral-hue))#121212
--neutral-20oklch(0.20 var(--neutral-chroma) var(--neutral-hue))#161616
--neutral-22oklch(0.22 var(--neutral-chroma) var(--neutral-hue))#1b1b1b
--neutral-25oklch(0.25 var(--neutral-chroma) var(--neutral-hue))#222222
--neutral-32oklch(0.32 var(--neutral-chroma) var(--neutral-hue))#333333
--neutral-35oklch(0.35 var(--neutral-chroma) var(--neutral-hue))#3a3a3a
--neutral-45oklch(0.45 var(--neutral-chroma) var(--neutral-hue))#555555
--neutral-50oklch(0.50 var(--neutral-chroma) var(--neutral-hue))#636363
--neutral-60oklch(0.60 var(--neutral-chroma) var(--neutral-hue))#808080
--neutral-70oklch(0.70 var(--neutral-chroma) var(--neutral-hue))#9e9e9e
--neutral-80oklch(0.80 var(--neutral-chroma) var(--neutral-hue))#bebebe
--neutral-85oklch(0.85 var(--neutral-chroma) var(--neutral-hue))#cecece
--neutral-90oklch(0.90 var(--neutral-chroma) var(--neutral-hue))#dedede
--neutral-92oklch(0.92 var(--neutral-chroma) var(--neutral-hue))#e4e4e4
--neutral-94oklch(0.94 var(--neutral-chroma) var(--neutral-hue))#ebebeb
--neutral-96oklch(0.96 var(--neutral-chroma) var(--neutral-hue))#f2f2f2
--neutral-98oklch(0.98 var(--neutral-chroma) var(--neutral-hue))#f8f8f8
--neutral-100oklch(1.00 var(--neutral-chroma) var(--neutral-hue))#ffffff

The number is the lightness. --neutral-70 is OKLCH lightness 0.70 — 70% of the way from black to white, perceptually. It is not a position in a list, which is what lets the steps be unevenly spaced and still readable: you can see that 92, 94 and 96 sit close together, where 400/500/600 would tell you nothing. It also survives a re-skin — neutral-80 is still tone 80 in a warm palette.

OKLCH, because a ramp needs even steps and only OKLCH gives them. Hex and rgb() carry no lightness at all — you cannot tell from #666666 how far it sits from #808080 without doing the arithmetic. hsl() looks like it solves that and does not: its lightness is a formula, not a perception, so a yellow and a blue at 50% read as wildly different brightnesses and an HSL ramp goes uneven and muddy. OKLCH is built to match the eye, so equal steps in L look equal — which is the only property a ramp actually needs. Two things fall out for free: the lightness is legible in the value, so the token name can be checked against it, and lightness sits on its own axis, so same lightness, add warmth becomes sayable. That second one is the tint knobs.

This reads opposite to Tailwind, where higher is darker because the number counts along a list. Here higher is lighter, because lightness rising means getting lighter. Nothing is inverted for effect; the two scales measure different things.

The spacing is uneven on purpose, and it was measured rather than chosen. Tone 0 to 15 covers only 11 of the 255 sRGB values, so there is nothing down there to name — a step at tone 5 renders #000000, identical to tone 0. Tone 90 to 100 covers 33, and light packs five roles into it, so they collide at five-unit steps and each get their own at two. Fives through the middle, twos at both ends where roles crowd.

The dark end had the same collision and nobody had measured it. Dark packs four roles into tones 15–35 — three surfaces plus border-subtle — and at five-unit steps two of them landed on one: --color-border-subtle and --color-surface-raised were both --neutral-25, so a subtle border on a card measured 1.00:1 — not faint, absent. The obvious nudge made it worse: one step up is --neutral-35, which is border-default, so moving subtle alone would have merged two ranks rather than separating them. The gap needed a step in it, not a different occupant, and --neutral-32 is that step. Light was never wrong and is untouched.

A step is a position on this ramp with a contrast table — never a Tailwind-compatible step, and never a promise about the surface underneath it. That distinction is not pedantry: the text ramp was borrowed from Tailwind for its “tested contrast” and a role failed at 2.40:1.

The first two rows are not steps — they are the ramp’s only dials, and every step below is written in terms of them. --neutral-chroma and --neutral-hue are both 0, so every step is a pure grey. Set them and the whole ramp tints at once, both modes together, with every lightness — and therefore every contrast ratio — unchanged. They are the entire re-skin surface.

They are different units and different shapes, which matters because the two names look alike. Chroma is a small decimal: 0.009 is a real tint, 0.03 is a colour, and sRGB’s most saturated values sit around 0.25–0.30, so the dial lives in its bottom few percent. Hue is degrees on a 0–360 wheel and is fine as a whole number — 60 warm brown, 90 olive, 200 cyan, 250 cool blue.

Keep chroma at or below 0.009. Room for it collapses approaching white, so the lightest step sets the ceiling for the whole ramp: at hue 60, neutral-60 takes 0.140 while neutral-98 takes 0.012 and neutral-100 takes none at all — pure white cannot be tinted in any direction. Past the ceiling nothing breaks, but the top steps clip while the middle keeps colouring, so the page reads less tinted than the cards and borders on it. A non-uniform tint is worse than a weak one.

Surfaces

Three elevations, named for what you see rather than for where they sit on the ramp.

TokenLightDark
--color-surface--neutral-98--neutral-20
--color-surface-raised--neutral-100--neutral-22
--color-surface-sunken--neutral-96--neutral-18

Each of the three points at a --neutral-* step, and the two modes point at different steps of the one ramp. That is what a role buys: raised means raised in both modes, and which step delivers it is an implementation detail rather than a rule you have to remember.

The page is the middle one, and that is the whole design. It needs room on both sides: raised above for things that sit on the page, sunken below for things set into it — the tint behind inline code, a nav hover, the groove of a segmented control. Put the page at either end and the ramp only goes one way, which makes one of the two relationships unsayable. Elevation comes free, because lighter reads as raised in both modes: that is how light behaves, not a theme preference. Pop is still carried by shadow and border, not brightness alone.

Films

Transparent values that work on any surface, because they never replace what is behind them.

TokenLightDark
--color-washcolor-mix(in srgb, var(--neutral-0) var(--opacity-1), transparent)color-mix(in srgb, var(--neutral-100) var(--opacity-1), transparent)
--color-scrimcolor-mix(in srgb, var(--neutral-20) var(--opacity-12), transparent)color-mix(in srgb, var(--neutral-0) var(--opacity-16), transparent)
--color-shadowcolor-mix(in srgb, var(--neutral-20) var(--opacity-3), transparent)color-mix(in srgb, var(--neutral-0) var(--opacity-12), transparent)

A surface is something. A film modifies whatever is behind it, which is why these three are the one place in the colour system where transparency is the value rather than a way of arriving at one. It also means they can be used somewhere a surface cannot: a control that has to sit on a page, in a card and on a docs stage without knowing which. They arenot the modal layer that word usually names — a scrim is one of these, but a drawer or a dialog is a surface like any other.

That is exactly what --color-wash is for. It is the resting ground of a control one step off the page — a segmented control’s track, the stage an example stands on. It was a surface until August 2026, and being an absolute colour meant a track could match the very thing it was supposed to be set into. A film cannot: a wash on a wash is simply one step deeper.

They invert by mode, and here that is the point. The surface ramp deliberately does not — raised means raised in both modes — but a film’s job is to step away from its ground, and which direction that is depends on the ground. So the ink is near-black on light and near-white on dark, and dark takes the heavier step: a light ink over a dark ground registers less than a dark ink over a light one.

None of the three is checked for contrast, and check:contrast names them rather than skipping them quietly. A scrim and a shadow sit over an unknown backdrop, so a single ratio would be a fiction. The wash is the exception that proves it: text is read against it, so it is measured as a stack — the page plus the film — the same way the interaction states are.

Text

Three ranks and an inverse, every one set by contrast rather than taken off a scale.

TokenLightDark
--color-text-primary--neutral-20--neutral-98
--color-text-secondary--neutral-45--neutral-85
--color-text-tertiary--neutral-50--neutral-70
--color-text-inverse--neutral-98--neutral-20

These are set by contrast, not taken from a scale. The ramp was borrowed from Tailwind neutral for its “tested contrast”, and tertiary then measured 2.40:1 on light — below even the 3:1 floor for large text. A scale step is a position on a ramp; it is not a promise about the surface underneath it.

Each value now clears 4.5:1 against all three surfaces, and the binding one is whichever surface is closest in lightness to the text — sunken on light, raised on dark. One rule with two answers; it read as the same surface in both modes until the dark ramp was turned round, which was a coincidence of the old ramp direction rather than a principle.

Icon

The same ranks one step softer, because an icon is held to a lower ratio than text.

TokenLightDark
--color-icon-primary--neutral-25--neutral-96
--color-icon-secondary--neutral-50--neutral-80
--color-icon-tertiary--neutral-60--neutral-60

The reason is computable rather than a preference: WCAG holds a meaningful icon to 3:1 where text owes 4.5:1, so an icon has headroom text does not. The optical half is that a filled glyph reads heavier than stroked text at the same colour — equal colour is not equal weight.

--color-icon-tertiary is the one worth looking at: --neutral-60 is the only step that clears 3:1 in both modes and 4.5:1 in neither, so it is the step text cannot use and an icon can — and it is the same step in both modes, which no text role manages. These aliased the text ramp until August 2026; the alias was the point of having the role, and this is the divergence it was waiting for.

They are assigned, never defaulted. Icon keeps fill: currentColor and no colour of its own, so it still inherits from whatever contains it — the mode flip, inverse inside a primary button, red inside an error message. Reach for these only to quieten an icon on purpose.

An icon library — the shapes rather than their colour — is a separate question and not a token: a token is a value, an icon is an asset.

Border

TokenLightDark
--color-border-subtle--neutral-90--neutral-32
--color-border-default--neutral-85--neutral-35
--color-border-strong--neutral-70--neutral-45
--color-border-focus--neutral-20--neutral-98

The first three are named for prominence, not lightness, which is what lets the same names invert across modes: subtle is a light grey on light and a dark grey on dark. Width is a separate category, and lives on its own page.

focus is not the top of that ramp. It is a fourth name on a different axis, and the only border here that owes a number: WCAG holds a focus indicator to 3:1 against what surrounds it, where the graded three owe nothing at all — subtle is meant to be barely there, so a bar would fail it for doing its job. It resolves to the same value as --color-text-primary, because the system has no accent to spend and the ring takes the strongest line the theme has. The active tab’s underline shares it for the same reason.

Control

The parts of an interactive control — what it fills with, the ground it switches off to, and its edge when empty.

TokenLightDark
--color-control-fill--neutral-20--neutral-98
--color-control-track--neutral-60--neutral-70
--color-control-border--neutral-60--neutral-70

Every other family names something you can point at — a surface is a page, text is text, a border is a line, an icon is a glyph. A switch’s track and a filled button’s body are none of those, and before these existed the first build of Switch reached for --color-brand, which is pure black and harsher than anything wanted here.

Named by job, not by rank — unlike --color-border-* directly above, there is no scale. fill is the system’s ink, what a checked box, a chosen radio and an on switch fill with. track is a switch’s off ground. Neither is Button’s secondary — that is fill washed to 4%, one rung of the solid / soft / bare ladder rather than a colour of its own.

border is an empty control’s edge — a text field, a select, an unchecked box — and it owes 3:1 under WCAG 1.4.11, because that edge is what identifies the control. No step of the border ramp clears 3:1 in either mode, which is why it is a name of its own rather than a heavier strong.

States

Translucent washes rather than colours, so one value works on anything without it declaring a background of its own.

TokenLightDark
--color-state-hovercolor-mix(in srgb, var(--neutral-20) var(--opacity-1), transparent)color-mix(in srgb, var(--neutral-98) var(--opacity-1), transparent)
--color-state-pressedcolor-mix(in srgb, var(--neutral-20) var(--opacity-2), transparent)color-mix(in srgb, var(--neutral-98) var(--opacity-2), transparent)
--color-state-selectedcolor-mix(in srgb, var(--neutral-20) var(--opacity-2), transparent)color-mix(in srgb, var(--neutral-98) var(--opacity-2), transparent)

Each swatch is split: bare surface on the left, the wash on the right. A flat patch would be the wrong specimen — at 4% these are almost exactly the page, so the seam is the token.

The ink comes from the ramp, not from a role. --color-text-primary would give the right value under the wrong name — a text colour doing a background’s job, which is exactly the borrowing these tokens exist to remove.

A near-page ink cannot work, which is why it is the far end of the ramp rather than a surface step. A wash only registers if its ink sits far from what it covers: --color-surface-sunken is four tone units off the page, so at 4% it moves the page by about a sixth of a tone — invisible. --neutral-20 under the same 4% moves it about three tones. The opacity supplies the subtlety; the ink has to supply the range.

pressed and selected hold the same value and are still two tokens. A press is momentary, where selected is persistent — one name covering both is a name doing two jobs, which is exactly what the nav’s borrowed surface was doing. Kept apart for the same reason --color-brand is kept apart from --color-text-primary: close today, free to diverge, and the divergence then costs one line.

Filled controls do not use these, and that is two mechanisms for two different jobs rather than a duplication. A 4% wash over a black button is invisible, so a fill shifts proportionally toward the page instead. A wash is right when the thing behind it is the page; a shift is right when the thing behind it is a fill. There is no -focus or -disabled: focus reuses the text colour as an outline, and disabled is an opacity on the whole control, so it dims label, icon and fill together.

Status

Four statuses, each with a foreground, a tinted surface and a border — the only place colour carries meaning.

TokenLightDark
--color-success#007e35#4ade80
--color-success-surface#f0fdf4#052e16
--color-success-border#80b995#186735
--color-success-strong#037231#5fe18f
--color-error#d61f21#f87171
--color-error-surface#fef2f2#450a0a
--color-error-border#ce9c9d#a22422
--color-error-strong#bf1e20#f88181
--color-warning#a75900#fbbf24
--color-warning-surface#fffbeb#451a03
--color-warning-border#c3ad51#944110
--color-warning-strong#965103#fbc63e
--color-info#2563eb#60a5fa
--color-info-surface#eff6ff#172554
--color-info-border#91abcd#2c52c1
--color-info-strong#235ad1#72affa

Each status gets three roles rather than one, because a message needs a foreground, a tint to sit on and an edge, and picking those by eye from a single hue is where inconsistency starts.

They are set by contrast, hue held exact and chroma reduced only where the gamut forced it. Every foreground clears 4.5:1 against the page, its own tint and a raised card. Warning is the visible cost: at 4.5:1 on a near-white page an amber has to become a dark gold, so it reads browner than a typical amber. That is arithmetic rather than taste.

Borders are held to 2:1 against their own tint, which is a chosen number rather than a standard — a status border owes nothing under WCAG while the tint and the coloured text are both present, since that rule covers visuals required to identify a state. But the light borders measured 1.08–1.32, which is not a soft edge, it is an invisible one. Dark already sat near 2:1, so both modes now read at one strength.

The tints are unchanged, because measuring found them already within 1.6 tone units of each other — the one part of the triple that was a coherent family to begin with. Names stay semantic: a warning that becomes gold must not need a rename, which is the same rule that gives --color-brand rather than --teal. Interaction states (-hover, -active, -disabled) are still absent — they cannot be named well without a component in front of you.

The -strong rung is the same hue under pressure, not a fifth colour. It mixes 12% of the ink into the foreground, and the number was found by looking: a first pass at 30% read as a different colour rather than the same one darkened, while 8% measured a real but slight 5.16. Only error has a consumer today — a field’s invalid edge, which has to out-read a hover that doubles it — and the other three exist so the family keeps one shape. That is deliberate rather than speculative, but it does mean three of these have never been judged against a real component, and the systematic pass that once produced a neon green border is the reason to give each an eye when its first consumer arrives.