Theming & dark mode
Dark mode is light-dark(), and the switch is color-scheme.
There is no second token block, no .dark class and no media-query fork. Four short
rules in tokens.css are the entire mechanism.
The mechanism
These are the rules. Everything on this page follows from them.
:root {
color-scheme: light dark;
--scheme-inverse: dark;
}
@media (prefers-color-scheme: dark) {
:root { --scheme-inverse: light; }
}
[data-theme="light"] {
color-scheme: light;
--scheme-inverse: dark;
}
[data-theme="dark"] {
color-scheme: dark;
--scheme-inverse: light;
}--scheme-inverse is always the opposite of the scheme beside it; only an inverse
band reads it (see below). If you set color-scheme yourself anywhere, set
--scheme-inverse beside it to the opposite value, or an inverse band inside that
element will match its surroundings and disappear.
light-dark() resolves against the element's used
color-scheme. Every semantic colour token in the system is a
light-dark() pair, so changing that one property re-resolves all of them at once,
not by re-declaring anything but because the values were never fixed in the first place.
--color-surface: light-dark(var(--color-white), var(--color-neutral-900));
--color-text: light-dark(var(--color-neutral-900), var(--color-neutral-100));
--color-brand: light-dark(var(--color-brand-600), var(--color-brand-400));Each token is declared once. There is no dark-mode block to keep in sync with the light-mode block, because there is no dark-mode block. A token that drifts between themes is not possible here: the two halves sit on the same line.
Three states, not two
A theme control has three positions, not two: system, forced
light, forced dark. System is the default and it follows the OS,
because :root declares color-scheme: light dark and the user agent
picks from that pair using the OS preference.
The detail that catches people: system means removing the attribute. There is no
data-theme="system": a third value would match neither of the two rules above, so
it would happen to work by accident, and then break the moment someone adds a
[data-theme] selector for something else.
<!-- system: no attribute at all -->
<html lang="en">
<!-- forced light -->
<html lang="en" data-theme="light">
<!-- forced dark -->
<html lang="en" data-theme="dark">Switching it is application code. @arag ships no theme-switching JavaScript: the
package is four CSS files. This is the whole of what an application needs:
var root = document.documentElement;
function setTheme(mode) {
// "system" is the ABSENCE of the attribute, not a third value.
if (mode === 'light' || mode === 'dark') root.setAttribute('data-theme', mode);
else root.removeAttribute('data-theme');
}If you remember the choice in localStorage, read it and call
setTheme() before first paint, or the page flashes the OS theme first. Store
nothing for system: an absent key and an absent attribute should mean the same thing, so
there is one representation of "no preference" rather than two.
It works on any subtree
The two override rules are plain attribute selectors, not :root[data-theme]. That
is the whole payoff. color-scheme inherits, so putting data-theme on
any element re-resolves every semantic token inside it: the same tokens, resolved differently,
with nothing extra declared.
Both panels below are on this page, in whatever theme you have selected. Each shows a surface, body text, a border and a solid brand fill.
Account balance
Account balance across all linked accounts
Account balance
Account balance across all linked accounts
The practical use is a dark hero band inside an otherwise light page, or a light-on-dark
sidebar next to light content. Neither needs a single extra token, an inverse palette, or a
set of --on-dark variants. Put the attribute on the wrapper and the contents
inherit a different resolution of the same names.
<body>
<section class="hero" data-theme="dark">
<h1>Welcome back</h1>
<p>Account balance</p>
</section>
<main>
<!-- still resolves light -->
</main>
</body>.hero {
background-color: var(--color-bg);
color: var(--color-text);
padding-block: var(--space-9);
padding-inline: var(--space-6);
}That rule names no theme. It is correct in a light page, in a dark page, and inside a forced
band in either, because --color-bg and --color-text are questions,
not answers.
An inverse band is the same mechanism. .u-bg-inverse sets
color-scheme to the opposite of the page, which CSS cannot say directly, so
--scheme-inverse carries it: tokens.css sets it beside every
color-scheme above, and because it inherits, the nearest ancestor wins and a band
inside a data-theme wrapper resolves correctly. Links, muted text, borders,
fields, cards and the focus ring inside the band all come out right with no override of their
own. The band's fill is the opposite theme's page ground (--color-bg, read in the
flipped scheme), so everything inside sits exactly as it would on a page in that theme. The one limit: an inverse band inside another inverse band matches the outer one, so put
data-theme on the inner one.
An element with data-theme should paint its own background.
base.css gives it the theme's text colour, but a forced-dark subtree that
sets no background-color still puts dark-mode text on the light page behind
it. The system cannot do this for you: it does not know which element in your subtree is
the surface.
Which tokens are theme-dependent
This is the distinction that decides whether a component themes or not. The raw ramp steps are
absolute: --color-brand-500 is
in every theme, on every subtree, always. They are
generated from a hue and a lightness ladder and they have no light or dark variant. The
semantic roles are the layer that knows about theme.
| Tokens | Theme-dependent | Why |
|---|---|---|
--color-brand-50 … --color-brand-950, and the neutral, success, warn and danger ramps |
No | Absolute oklch() values. 55 steps, each declared once. They are the raw material the roles are built from. |
--color-white, --color-black |
No | Literally the endpoints. Used as mix partners, not as surfaces. |
--color-bg, --color-surface, --color-surface-raised, --color-surface-sunken, --color-surface-inverse |
Yes | Each is a light-dark() pair over two ramp steps. |
--color-text, --color-text-muted, --color-text-subtle, --color-text-inverse, --color-text-on-solid |
Yes | The text ramp inverts, and --color-text-on-solid flips from white to near-black because dark-mode fills sit at the lighter 400 step. |
--color-border-subtle, --color-border-strong |
Yes | They sit a fixed distance from the surface, so they have to move with it. |
--color-border |
No, deliberately | Aliased to --color-neutral-500 in both themes. It delimits form controls, so WCAG 2.2 SC 1.4.11 applies, and L58% is the one ladder step that clears 3:1 against near-white and near-black. Still reference it as a role, not as --color-neutral-500. |
The four -solid families: --color-brand-solid, -hover, -active, -on-solid, and the success, warn and danger equivalents |
Yes | Fills land on the 600 step in light and the 400 step in dark. Their hover and active derivations follow, because the mix partner is itself theme-dependent (see below). |
--color-brand-subtle, --color-brand-text, --color-brand-border, and the same three for success, warn and danger |
Yes | Tints and readable text roles both invert. |
--color-surface-hover, --color-surface-active, --color-surface-selected |
Yes | Derived from --color-surface and --state-shade, both of which change. |
--shadow-penumbra, --shadow-umbra, and every --shadow-* built on them |
Yes | Dark mode uses a much heavier alpha; a light-mode shadow is invisible on a dark surface. |
--color-overlay, --color-highlight-bg, --color-highlight-text |
Yes | A scrim and a highlighter both have to sit correctly against whatever is behind them. |
--state-shade |
Yes | It is the token whose entire job is to know the theme: black given the neutral hue in light, white in dark. |
| Space, size, radius, border width, type, z-index, motion | No | Nothing non-colour is theme-dependent, and nothing should become so. |
Reference semantic roles, never ramp steps. A component that says
background-color: var(--color-brand-600) is a component that does not theme:
it will stay at the light-mode fill on a dark background and there is no rule anywhere that
will rescue it. Say var(--color-brand-solid) instead. The same applies to
--color-neutral-900 versus --color-text, and to
--color-white versus --color-surface. The ramp steps are public
so that new roles can be built from them, not so that components can reach past the roles.
Interactive states derive themselves
Hover and active fills are not hand-authored. There is one mix partner,
--state-shade, and it is theme-aware:
--state-shade: light-dark(
oklch(0% calc(var(--c-neutral-100) * 10) var(--hue-neutral)),
var(--color-white)
);
--state-hover-amount: 10%;
--state-active-amount: 18%;So a single color-mix() formula darkens in light mode and lightens in dark mode.
This is the actual definition of the brand hover token: there is no second one for dark:
--color-brand-solid-hover: color-mix(
in oklab,
var(--color-brand-solid),
var(--state-shade) var(--state-hover-amount)
);Resolved right now, in this page's theme:
at rest,
on hover.
Why warn is different
There is a second mix partner, --state-lift, and it always lightens:
--state-lift: var(--color-white);
--color-warn-solid: var(--color-warn-400);
--color-warn-on-solid: var(--color-neutral-950);
--color-warn-solid-hover: color-mix(in oklab, var(--color-warn-solid), var(--state-lift) var(--state-hover-amount));Orange is light at every step that reads as a fill, so warn carries a dark label in
both themes: --color-warn-solid and --color-warn-on-solid are the
only fill and label pair in the system that do not use light-dark() at all.
Shading such a fill walks the label's contrast down, not up. Measured with
--state-shade in light mode, warn went from 5.71:1 at rest to 3.55:1 on
:active, worse under the cursor than at rest, which is exactly backwards.
A fill hovers by moving away from its own label colour. Direction is a property of
the fill's label, not of the theme. Light label ⇒ --state-shade. Dark label ⇒
--state-lift. If you add a sixth ramp, that is the question to answer before you
write its hover token.
Both panels below are on this page. Each row is rest, hover, active, taken straight from the tokens: nothing here is hovering, the three swatches simply show what the three tokens resolve to. Brand shades in light and lightens in dark; warn lightens in both.
--color-brand-solid → shade
--color-warn-solid → lift
is --state-shade here.
--color-brand-solid → shade
--color-warn-solid → lift
is --state-shade here.
The brand row reverses direction between the two panels and the warn row does not. That is the point: one formula, two behaviours, because the mix partner is the thing that knows about theme.
Retuning a ramp
The ramps are generated, not picked. Each is one hue, plus a lightness ladder shared by all five ramps, plus a chroma ladder of its own. That means a ramp is retuned by editing one number.
:root {
--hue-brand: 262; /* blue-violet */
}Every brand step is oklch(lightness chroma var(--hue-brand)), so changing that one
declaration regenerates all eleven steps, and because the semantic roles are built on the
steps and the state tokens are built on the roles, everything downstream moves with it.
/* Application CSS, unlayered, so it wins over the tokens layer.
All 11 brand steps, --color-brand, --color-brand-solid, -hover, -active,
-subtle, -text, -border, --color-focus and --color-surface-selected
all move. No other declaration changes. */
:root {
--hue-brand: 285;
}The same applies to --hue-neutral, --hue-success,
--hue-warn and --hue-danger. Retuning --hue-neutral
additionally moves the shadow tints, which are mixed from --color-neutral-900
rather than from a literal, so the greys and the shadows of those greys never desynchronise.
Changing a hue does not re-check contrast. The lightness ladder is
unchanged, so the ratios move only a little, but they do move, because contrast depends on
chroma and hue as well as lightness, and several roles in this system sit close to the line
on purpose. --color-border clears 3:1 by a margin of 0.62 at its worst
measured pairing, and --color-text-on-solid sits one ladder step above where the
symmetrical choice would have put it precisely because the symmetrical choice measured
4.21–4.29:1. Re-run the contrast table on
the colour page after any hue change, in both themes.
Choosing the main colour
Retuning --hue-brand by hand, above, is one way to pick a main colour. The other
is one attribute on the root element, choosing from seven built-in presets:
<html lang="en" data-brand="teal">The seven presets are mono, red,
orange, green, teal,
blue and violet. blue is the shipped default, and
writing it is a true no-op: data-brand="blue" and omitting the attribute
entirely compute identically, because the preset restates the value :root
already holds. See the seven rendered swatches on
the colour page.
Unlike data-theme above, data-brand does not scope to any
subtree: it only works on the root element. The reason is mechanical, not an oversight.
data-theme switches color-scheme, and light-dark()
resolves against the used color-scheme of the element that reads it,
so it scopes for free wherever you put the attribute. A brand preset instead re-points
inputs to var() substitutions that already happened where
--color-brand-600 is declared, which is :root: the ramp
upstairs was computed before the subtree was reached, and what inherits down is the
finished colour rather than the recipe.
Put the attribute on the root. What a subtree actually gets is partial, and the
two kinds of preset are partial in different ways, which is the real reason to keep it at
the root rather than a rule the cascade enforces. A hue preset on a
subtree re-points --hue-brand and its chroma ladder but restyles nothing, so
you get a subtree that reports --hue-brand: 25 while every brand colour in it
is still blue. The achromatic preset (mono) does apply in
full, because it re-points the semantic roles directly and those are what components read.
Relying on either behaviour is relying on an implementation detail.
The other detail worth knowing: mono is one colour that flips
with the theme (black on a light page, white on a dark one) rather than a fixed shade.
It shipped as two literal presets, black and white, each holding
its colour in both themes. Being literal cost each of them its edge in exactly one theme: a
literal white fill sat on a white surface in light mode at 1.00:1, and a literal black one
on a near-black surface in dark at 1.35:1, and both needed a ring drawn around them to stay
findable. A preset that flips is never in that position: the fill is always at the opposite
end of the lightness range from the ground behind it, so it separates on its own in both
themes, 21.00:1 light and 15.53:1 dark. If you want a fixed black or a fixed white that does
not follow color-scheme, set the brand roles yourself; the neutral roles also
already cover most of that ground.
Two presets keep small brand text neutral. Under orange, as under
mono, --color-brand-text is the ordinary text colour in both
themes, so links, tab labels and u-color-brand read as ink, and the colour lives
in the fill, the edge and the graphics: the solid button, the 1px border, the focus ring, the
tab indicator, the progress fill, the info alert's icon and the brand badge's dot. The reason
is that orange turns brown as it darkens, so no orange step is both dark enough to carry small
text and still orange. The solid fill is --color-brand-400 with a near-black
label in both themes, and hover and press lighten it rather than darken it, the same shape
the warn family uses. Two things follow from that hue. The brand (45) would sit 3 degrees from
warn (48), which made an info alert and a warn alert the same box told apart by icon shape,
so under this preset alone the warn family moves to amber: --hue-warn is 80, with
six chroma steps of its own that stay inside sRGB. Warn's rule is unchanged (bright steps
only, ink for small text); the cost is that a solid warn button is brass rather than orange.
And in dark mode --color-brand-subtle is the plain surface,
so a tint that reads as brand elsewhere (u-bg-brand, say) shows nothing there.
A custom main colour, from a seed
The seven presets above cover the common case. A consumer with an existing brand colour has
two ways in, and the difference between them is only how much the browser does. Read the
"Custom main colour, from a seed colour" block near the end of src/tokens.css
for the full mechanism: this is the short version, plus a helper that writes the lines
for you.
Tier 1, the hue angle. One line, works everywhere in the support window:
:root { --hue-brand: 212; }Tier 2, the seed colour. One line, and the hue is derived in CSS with relative colour syntax:
:root { --brand-seed: #1f6fd0; }Tier 2 sits behind @supports (color: oklch(from red l c h)). Firefox
120–127 does not support that query, so there --brand-seed is silently
ignored and --hue-brand still drives the ramp. That is the whole reason to
write both lines rather than the seed alone: Tier 2 where it is
supported, Tier 1 everywhere else:
:root {
--hue-brand: 256;
--brand-seed: #1f6fd0;
}Accepts #rgb,
#rrggbb, rgb(r, g, b) or rgb(r g b), or use the
colour picker.
:root {
--hue-brand: 256;
--brand-seed: #1f6fd0;
}--color-brand-600
These differ on purpose: chroma always comes from the
shared --c-brand-* ladder, never from your seed, so every ramp stays inside
sRGB and every step stays comparable to the others.
The shared --c-brand-* ladder peaks at 0.178 chroma, which does not fit
inside sRGB for every hue. At this hue the ramp would overflow and the browser would
gamut-map it, compressing the steps toward each other, so paste this block alongside
the hue line, the same way [data-brand="green"] and
[data-brand="teal"] already borrow or scale a narrower ladder, and
[data-brand="orange"] sets a few steps of its own, in
src/tokens.css:
Live preview, both themes
--brand-seed must be a valid CSS colour, and there is no way in CSS to test
one. An invalid seed is invalid at computed-value time, which does not
fall back to Tier 1: every --color-brand-* step becomes invalid, and each
property reading one takes its own inherited-or-initial value instead. Measured with a
seed of banana inside a parent whose colour was
rgb(10, 200, 30):
color: var(--color-brand-600) → rgb(10, 200, 30) inherited
background-color: var(--color-brand-solid) → rgba(0, 0, 0, 0) initial
border-color: var(--color-brand-border) → rgb(10, 200, 30) inheritedSo a typo does not turn your brand black: it makes solid fills transparent and
brand text the colour of whatever surrounds it, and a primary button goes invisible rather
than merely wrong. It is the same mechanism described under
graceful degradation. The helper
above validates its input before it ever prints a --brand-seed line and only
emits a colour that parsed, which is the argument for pasting its output rather than
typing the line by hand.
Elevation is asymmetric, on purpose
--color-surface and --color-surface-raised are the same colour in
light mode and a full ramp step apart in dark mode:
--color-surface: light-dark(var(--color-white), var(--color-neutral-900));
--color-surface-raised: light-dark(var(--color-white), var(--color-neutral-800));That is not an oversight. In light mode, elevation is carried entirely by shadow: that is how light UIs actually read, and a grey card on a white page reads as disabled, not as raised. In dark mode a shadow is nearly invisible against a near-black surface, so elevation has to come from lightness instead, and the two surfaces differ by a real step.
Never rely on --color-surface-raised being a different colour from
--color-surface. In light mode it is not. Pair it with a shadow token, always:
/* Correct: reads as raised in both themes. */
.card {
background-color: var(--color-surface-raised);
box-shadow: var(--shadow-md);
}
/* Wrong: invisible in light mode. */
.card {
background-color: var(--color-surface-raised);
}The same reasoning runs the other way for --color-surface-sunken, which is mixed
rather than taken from a ladder step: the even 8% lightness ladder puts
--color-neutral-100 at 90%, which is a real grey rather than a whisper, and there
is no step below --color-neutral-950 at all. Sunken needs a value between the
steps at both ends, so it is derived with color-mix(), still generated, still one
source.
Forced colours and high contrast
Honest status: the system does not currently special-case
@media (forced-colors: active). There is no forced-color-adjust
anywhere in the package and no system-colour keyword fallbacks.
In that mode the OS overrides colour properties with its own palette, which means the computed
results of light-dark() and color-mix() are discarded: the whole
theming mechanism on this page stops being what decides the colours. In practice most of the
system degrades acceptably, because the reset and base layers use borders and real focus
outlines rather than colour alone, and because --color-border delimits controls
structurally. But that is an expectation, not a measurement.
Treat forced-colors support as untested. The known gaps are: solid fills lose their fill and
keep only their text, so a primary and a secondary button become indistinguishable; anything
that communicates state purely through --color-success or
--color-danger loses that signal; and shadow-only elevation disappears entirely,
which interacts badly with the asymmetric surface decision above. The fix is a
forced-colors block that restores borders where colour was carrying the meaning.
It is not written yet, and this page will say so until it is.