Skip to content

Theming

Turn on dark mode, scope a theme to a subtree, and override a single token value.

View source View as Markdown

Every generated token file is a flat block of resolved literals under one selector, and the selectors are ranked: a colour mode beats the base, a theme beats a colour mode. Whichever order the files load in, the right one wins.

[data-color-mode='dark'][data-color-mode] {
  --app-color-surface-bg-base: oklch(0.2 0.002 264);
  --app-color-primary-bg-solid: oklch(0.96 0.003 264);
}

The repeated attribute is the ranking. Each repetition adds one to the selector’s specificity without changing what it matches, so the dark file outranks :root and a theme file, with two repetitions, outranks both.

Nothing aliases anything at runtime, so cascade order is the whole of the mechanism. We did look at a palette block with the semantic tokens pointing into it through var(), and dropped it. When any link in a var() chain resolves to something invalid, the declaration goes invalid at computed-value time, and that falls back to the inherited value rather than to the previous declaration in the cascade, so a component inside a themed container would take its parent’s colour with nothing in the console to say so. A literal fails the other way round, since a value the browser cannot parse is dropped at parse time and the previous declaration wins.

Everything below describes the files the stock mirror.config.ts builds and the selectors it gives them. A project that writes its own config decides both, so if yours does, the file names and the attributes here are whatever you named them. See Configuration.

Files

List the base file and whatever themes you want to offer in the css array of nuxt.config.ts, in any order. Without Nuxt, import the same paths at your entry point instead.

css: [
  '~~/.maas/tokens/css/application.css',
  '~~/.maas/tokens/css/theme/dark/application.css',
  '~~/.maas/tokens/css/theme/mono/application.css',
]

Adding a file adds the theme and removing it takes it away again, with no registration step in between.

Dark mode

Dark mode ships as the second file in that list, and it selects on one attribute.

<html data-color-mode="dark">

Once the attribute is set, every Mirror class is already correct. bg-surface-base, text-surface-muted and the rest resolve to different literals underneath it, so neither a component nor a class name changes.

If you are using Nuxt, let @nuxtjs/color-mode write the attribute for you. Set dataValue to 'color-mode' in nuxt.config.ts and the module writes data-color-mode="dark" onto <html>, which is the selector the dark file already carries.

colorMode: {
  classSuffix: '',
  dataValue: 'color-mode',
}

There is a dark: variant too, bound to that attribute rather than to prefers-color-scheme, so a user’s choice beats the system default. Reach for it only when a mode needs genuinely different tokens, rather than different values of the same token.

Themes

data-theme is the second axis. Set it to the name of a theme you listed, mono for theme/mono/application.css, and every property that file declares applies from that element down.

AttributeSelector
data-color-mode
[data-color-mode='dark'][data-color-mode]
data-theme
[data-theme='mono'][data-theme][data-theme]

Both are attribute selectors, so either one applies to a whole document or to any subtree.

<template>
  <section data-theme="mono"></section>
</template>

Overriding one value

The custom properties are ordinary CSS and they inherit, so a single override needs no build step and no config.

.marketing-hero {
  --app-color-surface-bg-base: oklch(0.18 0.04 264);
}

Scope it to the smallest element that needs it, and everything inside picks it up while everything outside keeps the base value.

Further reading

  • Configuration: add a build target so a whole theme compiles into its own file, and pick its selector from the ladder.
  • Overview: the two layers, and how to read a token path.