Tokens¶
Six families, twelve steps each, plus an ink token per family and one card surface. 79 colour tokens in total, and every one of them was solved rather than picked.
The 12-step role model¶
1 page 7 border, normal (WCAG 1.4.11: 3:1)
2 subtle page 8 border, hover
3 component, rest 9 solid (the brand moment)
4 component, hover 10 solid, hover
5 component, active 11 text, low contrast (WCAG 1.4.3: 4.5:1)
6 border, subtle 12 text, high contrast (7:1)
Every step is a role, not a shade. --brand-7 is "the border of a control in the brand
family", and the reason it is that lightness is that 3:1 against step 3 is where it landed.
Use it for a border; do not use it for text. Why solved colour
has the full contract table.
Every family, live¶
The swatches below are painted with var(--<family>-<step>) and nothing else. If one
renders empty, that token does not exist in the stylesheet — this page cannot advertise a
token the package does not ship.
Press the light/dark toggle in the header. Every strip above re-resolves; nothing on this page redeclares a token.
Two things to notice.
info is the neutral scale. Byte for byte. It is the state that means nothing is wrong,
and spending a hue on it collided with the brand under deuteranopia —
the full reason.
The greys are not grey. They carry a constant chroma of 0.007 around the brand hue. Not a cap, a constant: an earlier version capped chroma at 0.022 and the cap only bound from step 6 up, so neutral steps 1–5 came out byte-identical to the brand's and a brand-tinted callout background was inexpressible. In a system whose premise is that colour does work, that is the defect that matters most.
The shipped hex values, hue 285
Light mode:
| 1 | 3 | 6 | 7 | 9 | 11 | 12 | |
|---|---|---|---|---|---|---|---|
| neutral | #f8f8fd |
#eaeaef |
#c9c9ce |
#85858a |
#75757a |
#69696e |
#4c4c51 |
| brand | #f8f8f9 |
#eaeaf0 |
#c7c8e3 |
#817dcb |
#755cf5 |
#6759c9 |
#4a4583 |
| danger | #f9f8f8 |
#f0e9e8 |
#e4c2c0 |
#d16564 |
#e3263d |
#af484a |
#703f3e |
| warn | #f9f8f7 |
#f0eae3 |
#e4c5a4 |
#a57f54 |
#c68022 |
#86633b |
#5b4a37 |
| ok | #f7f9f7 |
#def0dc |
#95db8d |
#599453 |
#2ba421 |
#40763a |
#3a5337 |
Dark mode:
| 1 | 3 | 6 | 7 | 9 | 11 | 12 | |
|---|---|---|---|---|---|---|---|
| neutral | #101014 |
#202024 |
#353539 |
#6b6b6f |
#919296 |
#87878c |
#ababb0 |
| brand | #101015 |
#1f1e32 |
#332e63 |
#685ebf |
#8a7ef7 |
#837ed4 |
#a8a8ce |
| danger | #140f0f |
#2d1c1b |
#532928 |
#aa4f4f |
#f84b55 |
#d96262 |
#d29f9d |
| warn | #12100f |
#251f19 |
#413323 |
#856543 |
#c78022 |
#ac7f4e |
#c9a580 |
| ok | #0f110f |
#1a2319 |
#253b22 |
#477642 |
#2faf24 |
#53974c |
#85b97f |
--surface is #ffffff in light and #202020 in dark. The stylesheet itself carries
OKLCH, not hex — these are for reading, and the hex is what a browser paints.
The two brand moments¶
A filled button inverts the polarity of the page it sits on. On a light page it is a dark object with light text; on a dark page it is a light object with dark text. Material 3 has specified this for years and most systems still solve one value and use it in both modes.
So step 9 differs by mode:
| step 9 | ink | on its page | ink ratio | |
|---|---|---|---|---|
| light | #755cf5 L 0.585 |
white | 4.31:1 | 4.57:1 |
| dark | #8a7ef7 L 0.660 |
dark | 5.79:1 | 5.92:1 |
#8a7ef7 · L 0.660 · dark ink#755cf5 · L 0.585 · white inkthe same three components, in both modes, on one page
forced light
forced dark
The dark value is the glow — L 0.66, which is where both
Zima Blue (#009fe3, L 0.667) and the accent I
had used for years (#8b7cf6, L 0.657) already sat. Those two are 48° apart in hue and
read as the same kind of colour.
Always pair step 9 with --<family>-ink
/* right */
.primary { background: var(--brand-9); color: var(--brand-ink) }
/* wrong, and it is the commonest contrast defect there is */
.primary { background: var(--brand-9); color: white }
Which ink is legible is decided per hue and per mode. White sits on the violet
solid, dark on the amber one, dark on the dark-mode glow. --warn-ink is dark in
both modes, because no amber exists that carries white text and is still amber.
The untinted card surface¶
--neutral-1 is the page and it carries the 0.007 tint. --surface is the card and it
carries no chroma at all: pure white in light mode, and a flat #202020 in dark.
That inversion is the point. The page's tint reads as light precisely because the card beside it has none — the eye is comparing, not measuring. It is copied from an observation rather than invented, and it fixes a measured defect: achroma's raised surface managed 1.044:1 against its page, which was proved invisible. This pairing gets a chroma step as well as a lightness step, so it separates on two channels instead of one.
the page is --neutral-1 · the card is --surface
A card on the page
1.060:1 in light mode, 1.171:1 in dark — plus a chroma step, plus a hairline, plus a shadow.
Use --surface for cards, dialogs, popovers, menus and the combobox listbox. Anything that
sits on the page rather than being the page.
One token block, not three¶
The obvious emit is a :root block, a .dark block, and a prefers-color-scheme copy of
the dark block. Three copies of eighty tokens, and the third exists only because a media
query cannot join a selector list.
light-dark() collapses it to one:
It has been Baseline since 2024, it keys off the inherited color-scheme, and it buys
something the class approach cannot do at all.
Nested themes are free¶
Put color-scheme: dark on a single card inside a light page and every token inside that
card flips — because custom-property substitution resolves against the element that
consumes the value, not the one that declared it. With .dark blocks you would have to
redeclare all eighty tokens on the card.
a dark island on a light page, with no token redeclared
This panel is forced light.
And this one is forced dark, inside it.
The cost is honest: there is no fallback. A browser without light-dark() gets no
colours at all rather than degraded colours. In 2026 that is the right trade, and the test
suite asserts the function is actually used, so the decision cannot rot quietly.
Two gamuts, both proven¶
The tokens above are sRGB. A second palette is solved against the Display-P3 boundary, proven separately, and emitted behind a media query:
@media (color-gamut: p3) {
:root {
--brand-9: light-dark(color(display-p3 0.44545 0.34988 0.95961),
color(display-p3 0.5305 0.48541 0.96659));
}
}
color(display-p3 …), not an out-of-range oklch(), and that is the decision worth
explaining. Emitting a colour outside sRGB and letting the browser gamut-map it hands the
final value to code that never measured it: gamut mapping is the browser's algorithm, it may
move lightness to preserve hue, and it therefore changes the contrast ratio by an unmeasured
amount. A palette proven in sRGB and mapped by someone else's code is a palette proven on
some screens. Explicit P3 components pin the value that was verified.
What the wider gamut buys, measured at step 9:
| chroma gained | |
|---|---|
| brand solid | +7% |
| brand glow | +8% |
| danger | +13% |
| warn | +15% |
| ok | +17% |
Modest, and worth having where the hue is the point. Below L 0.5 at hue 285 it is about +2%, so most of the ramp is unchanged — P3 does not move a page background.
The P3 block is audited from its shipped digits rather than from the solver's objects: parsed back out of the stylesheet, gamma-decoded, and re-measured with the P3 luminance coefficients. The build-time assertions run before a matrix multiply, a gamma encode, a clamp and a round to five places; only reading the file back catches a transposed row or a rounding step that pushed a border under 3:1. Same lesson as everywhere else in this package: an assertion that shares its premise with the thing it audits is not an audit.
Both palettes must pass or nothing is written. A P3 block that fails its own contracts is worse than no P3 block, because it only misbehaves on the better screens.
If you do not want the block, --no-p3 omits it:
Theming¶
color-scheme drives everything, so forcing a mode is one declaration rather than a second
palette:
<html> <!-- follows the OS -->
<html class="dark"> <!-- forced dark -->
<html class="light"> <!-- forced light -->
<div class="dark"> <!-- just this subtree, tokens and all -->
[data-theme="dark"] and [data-theme="light"] work identically, for frameworks that
prefer attributes.
This site is themed exactly that way. mkdocs-material's toggle writes
data-md-color-scheme, and one rule in docs/stylesheets/extra.css maps it to
color-scheme — after which every nilam token on every page follows, with no second block
anywhere.
Your own hue¶
Everything re-derives: the twelve steps of six families, both modes, both gamuts, the semantic hues, and the inks. Then it is proved, and a hue whose palette cannot satisfy the contracts fails the command rather than emitting a file.
Most hues fail, and that is a finding rather than a bug. Only roughly 285–315 clears the
dichromacy floor against a red/amber/green status set. If your hue is a brand asset that
predates the palette, --strict-brand-hue is the honest route through —
the whole argument.
Cascade layers¶
That statement is the first line of nilam.tokens.css, and it is what fixes the order for
every file loaded after it. Anything you write unlayered beats all of it. No
!important needed — a design system that needs !important to override is a design
system people fork.
There is exactly one !important in the package: [hidden] { display: none !important }.
Without it, a .flex { display: flex } in your own CSS silently un-hides a hidden element.
That was measured in a real app.
It also has a real cost, and this site pays it: mkdocs-material ships its own theme toggle
as a hidden element that it un-hides with CSS, and an important declaration in a layer
beats unlayered author CSS. So these docs load tokens, scale, components and widgets but
not nilam.base.css. The reasoning is written out at the top of
docs/stylesheets/extra.css.
Everything that is not a colour¶
nilam/scale.css — hand-authored, because none of it is solvable the way colour is. There
is no contrast requirement to invert for "how big is a heading". What the source does
instead is state where each number came from.
| Group | Tokens | The reasoning in one line |
|---|---|---|
| type | --text-000 … --text-7, --text-display |
a 1.2 minor third, fluid between 360px and 1280px. 1.2 rather than 1.25 or 1.333 because a big ratio runs out of room by the third size inside one card. |
| weight | --weight-thin … --weight-bold |
200 exists because --text-display recommends weight 200 and the first version shipped nothing below 400 |
| leading | --leading-tight … --leading-loose |
line height falls as size rises; 1.6 on a display heading looks like a gap |
| tracking | --tracking-tight … --tracking-micro |
tracking rises as size falls, which is the same rule backwards |
| measure | --measure |
65ch, the one typographic number with real literature behind it |
| space | --space-0 … --space-9 |
a 4px grid in rem, doubling from --space-3 up |
| radius | --r-1 … --r-full |
small gaps at the bottom, because that is where nesting happens: an inner radius should be the outer minus the padding between them |
| lines | --line-1, --line-2 |
a hairline as a token, so a 3× display does not get 3 device pixels |
| motion | --dur-0 … --dur-3, three easings |
150ms is where a transition stops reading as "responding" and starts reading as "loading" |
| focus | --ring-width, --ring-offset |
the offset is not cosmetic: 1.4.11 wants 3:1 against adjacent colours, and the gap guarantees the adjacent colour is the page |
| elevation | --shadow-1/2/3, --rim, --scrim |
see below |
| stacking | --z-base, --z-sticky, --z-overlay, --z-max |
only four, and three are legacy — dialogs and popovers live in the browser's top layer, which no z-index reaches |
Elevation, and the tenfold rule¶
The alphas inside --shadow-* differ by an order of magnitude between modes, and it is the
least obvious thing in the file.
Browsers composite alpha in gamma-encoded sRGB, not linear. Black at 8% over a near-white page darkens it a lot; the same black at 8% over a near-black page changes almost nothing, because the encoding has already spent most of its precision at the dark end. Measured, the gap needed to look equal is about 10× — the alpha producing the same OKLab lightness drop over each ground, 10.5× at a subtle step falling to 7.9× at a heavy one. The shipped alphas are 11×, which is consistent. An earlier version of this page said 23×; no metric reproduces that and it was wrong.
Which is why "our dark mode shadows look flat" is a universal complaint, and why dark themes that work switch to a light rim instead. Both are here:
--shadow-1/2/3— mode-aware alphas, from 0.05 in light to 0.55 in dark at the first level.--rim—inset 0 1px 0in white at 6%, and transparent in light mode, where it would read as a scratch. In dark mode it is what makes a raised surface look raised.
shadow-1, shadow-2, shadow-3 — each with --rim
--scrim is heavier in light mode for the same reason in reverse: dimming a bright page
takes more than dimming a dark one for the same perceived separation.
Getting the tokens out of CSS¶
Figma variables, Swift, Kotlin, Style Dictionary and DTCG 2025.10 — all from one function, nothing hand-maintained. See Platform export.