@arag 0.1.0

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');
}
Persisting the choice

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.

data-theme="light": Light

Account balance

Account balance across all linked accounts

data-theme="dark": Dark

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.

One caveat

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.

What changes with color-scheme and what does not.
TokensTheme-dependentWhy
--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.
Rule for component authors

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.

The design rule

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.

data-theme="light": Light

--color-brand-solid → shade

--color-warn-solid → lift

is --state-shade here.

data-theme="dark": Dark

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

Warning

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.

It is root-only in v1

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.

Two lines, and the helper below always writes both

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;
}
your colour generated --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.

Live preview, both themes

data-theme="light" Solid badge
Alert in your brand colour.
data-theme="dark" Solid badge
Alert in your brand colour.
Sharp edge

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

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

Consequence for component authors

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.

Unfinished

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.