5 · Dark mode is not inverting the colours¶
Dark mode is not the light palette upside down. Some values have to flip, some must not move at all, and one has to flip the other way from everything else.
What inverting actually does¶
filter: invert(1) is the shortcut, and it is instructive to look at:
four status badges
the same four, inverted
Inversion maps every hue to its opposite. Red "failed" becomes cyan and green "active" becomes magenta. The contrast ratios all survive perfectly — and every colour now means the wrong thing.
Photographs go negative too, which is why every naive dark mode has one page with a photographic X-ray on it.
But the deeper problem is not the hues. It is that inverting the ramp maps step 1 onto step 12, step 3 onto step 10, step 7 onto step 6 — and those pairs are not the same job. Step 1 is a background in both modes. Step 12 is text in both modes. Roles do not invert; they stay exactly where they are and take new values.
Polarity¶
Here is the idea the whole chapter turns on.
Polarity is the direction "forward" points. On a light page, a foreground object is darker than the surface behind it. On a dark page, a foreground object is lighter.
That means each token has to be asked afresh in each mode:
| Token | Light mode | Dark mode | Which way? |
|---|---|---|---|
| page (step 1) | very light | very dark | flips |
| text (step 12) | very dark | very light | flips |
| control border (step 7) | darker than the card | lighter than the card | flips |
| a raised card | lighter than the page | lighter than the page | does not flip |
| a filled button (step 9) | dark object, light text | light object, dark text | flips its own contents too |
The last two rows are where systems go wrong.
The filled button has to invert its page¶
A filled button is a raised object. To read as raised on a light page it must be darker than the page and carry light text. To read as raised on a dark page it must be lighter than the page and carry dark text.
So step 9 is the one step in the ramp whose lightness is genuinely different in the two modes:
| 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 |
Material 3 has specified this for years — dark-theme primary is tone 80 with a tone-20
onPrimary. Most systems still solve one value and use it in both modes.
one class, both modes, no dark: variant anywhere
forced light
forced dark
and the same button with color: white hard-coded
forced light — fine
forced dark — white on the glow
The bottom-right label is white on the light violet glow. It measures 3.28:1 where 4.5:1 is required; the solved --brand-ink gets 5.92:1 on the same button. A hard-coded color: white on a filled button is the most common contrast defect in comparable systems, and it is invisible to whoever wrote it, because they were looking at the mode where it works.
--<family>-ink¶
Which ink is legible on step 9 is decided per hue and per mode. That is not something a component author can be expected to know, so it is a token:
/* right */
.primary { background: var(--brand-9); color: var(--brand-ink) }
/* wrong */
.primary { background: var(--brand-9); color: white }
White sits on the violet solid. Dark sits on the amber one. Dark sits on the dark-mode glow.
And --warn-ink is dark in both modes, because no amber exists that carries white text and
is still recognisably amber — a measured fact, not a preference, and chapter 12 has the story
of the dark olive that shipped before it was understood.
step 9 and its own solved ink, five families, both modes
forced light
forced dark
Look at the third chip. Amber keeps dark ink in both modes while the other four flip.
The glow¶
The dark-mode solid sits at L 0.66, and the reason is worth a paragraph because it is the clearest evidence in the project that lightness is the variable, not hue.
| hex | L | C | hue | |
|---|---|---|---|---|
| Zima Blue | #009fe3 |
0.667 | 0.147 | 238 |
| an accent used for years by eye | #8b7cf6 |
0.657 | 0.176 | 286 |
| what an early solver produced | #6223f1 |
0.500 | 0.269 | 285 |
The first two are 48 degrees apart in hue and read as the same kind of colour — that lit, backlit quality. The third is one degree from the second and reads as nothing like it. It reads as pigment, or as highlighter ink.
Hue moved 48 degrees and the character survived. Lightness moved 0.16 and it did not.
The honest part: GLOW_L = 0.66 is chosen, not derived. Four attempts were made to
produce it from a constraint and none did — they give 0.603, 0.595, 0.570 and 0.575. In light
mode the constraints bind and pick the value for you. In dark mode they do not: dark ink still
has 1.3:1 of headroom at 0.66, so something has to fill the gap and it is taste, measured off
two colours that work. That is the one place in nilam's palette where the answer is judgement,
and Honest limits says so on its own page.
The elevation problem¶
In light mode, "raised" is easy. The page is L 0.980 and the card is L 1.000 — the card is lighter, and a shadow underneath it does the rest.
In dark mode both halves of that break.
There is more room, and it is the wrong kind. The dark page is L 0.175 and the card is L 0.246, a bigger lightness step than light mode gets. But you cannot keep going: text lives from L 0.626 upwards, and a card that bright stops being a surface and starts being a highlight.
And the shadow stops working. A black shadow over a near-black page changes almost nothing. That is not a subtle effect and it is not a rendering bug — it is chapter 6, and it is the single most startling piece of arithmetic in this whole subject.
The short version of the fix: dark mode needs a light rim along the top edge as well as a shadow beneath, because the rim is the part that still has somewhere to go.
forced light — shadow does it
forced dark — rim does it
light-dark(), and the thing it gives away free¶
The obvious way to ship two modes is three blocks of CSS: a :root block, a .dark block,
and a @media (prefers-color-scheme: dark) copy of the dark block. That third copy exists only
because a media query cannot join a selector list.
light-dark() collapses all three into one declaration:
--brand-9: light-dark(oklch(0.5850 0.2186 285), oklch(0.6600 0.1739 285));
/* ^ used when the element's color-scheme is light
^ and when it is dark */
It keys off the inherited color-scheme property, and it has been Baseline since 2024.
("Baseline" is the web platform's term for a feature supported in all current major browsers.)
color-scheme is itself doing real work beyond your own colours: it tells the browser which
way round to draw scrollbars, form controls, the page canvas and the caret. Setting it is how
you stop a dark page having a white scrollbar.
Now the part that is genuinely free. Put color-scheme: dark on one element in the middle
of a light page and every token inside it flips:
a dark island on a light page, with no token redeclared
This panel is forced light.
And this one is forced dark, inside it.
Why that works: custom-property substitution resolves against the element that consumes
the value, not the one that declared it. The declaration on :root is a recipe, and it is
cooked wherever it is used. With .dark class blocks you would have to redeclare all eighty
tokens on that card.
The cost is stated plainly: there is no fallback. A browser without light-dark() gets no
colours at all, rather than degraded colours. That is a deliberate trade and it is the one
limitation in nilam that is a hard cliff rather than a soft edge.
How nilam handles it¶
One token block. Not three.
<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. All four of those do exactly one thing: set color-scheme. Everything else
follows.
Step 9 differs by mode and --brand-ink follows it, so .n-btn-fill is one rule with no
dark: variant anywhere. The shadow alphas differ by mode, the rim exists only in dark mode,
and the scrim is heavier in light mode — all of which chapter 6 explains.
The site you are reading is themed exactly this way. mkdocs-material's toggle writes a
data-md-color-scheme attribute, and one rule in docs/stylesheets/extra.css maps it to
color-scheme. After that every nilam token on every page follows, with no second block
anywhere. Press the toggle in the header and watch the demos above re-resolve.
Next: shadows, and why every dark theme looks flat.
Reference: The two brand moments · One token block, not three · Nested themes are free