@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. Three rules in tokens.css are the entire mechanism.

The mechanism

These are the three rules. Everything on this page follows from them.

:root {
  color-scheme: light dark;
}

[data-theme="light"] {
  color-scheme: light;
}

[data-theme="dark"] {
  color-scheme: dark;
}

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-bg:      light-dark(var(--color-neutral-50),  var(--color-neutral-950));
--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 itself ships no JavaScript at all — 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

Дансны үлдэгдэл

data-theme="dark" — Dark / Харанхуй

Account balance

Дансны үлдэгдэл

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" lang="mn">
    <h1>Дахин тавтай морил</h1>
    <p>Дансны үлдэгдэл</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.

One caveat

An element with data-theme should paint its own background. A forced-dark subtree that sets color but not background-color 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. light-dark(black, white).
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(var(--color-black), 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));

Amber 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.7:1 at rest to 3.54: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: 255; /* deep blue */
}

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.

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.