Dark mode is a mode, not a brand

Spektral has two ways a token can vary. A brand is picked when you build: Acme or Globex, one stylesheet each. A mode is picked by the browser at runtime: light or dark, high contrast. Both ship in one stylesheet, and a single attribute on the page switches between them.

BrandMode
Chosen byYou, at build timeThe user or their OS, at runtime
On diskA full set of layer filesA sparse overlay: only what changes
OutputA separate stylesheetExtra rules appended to the same stylesheet
Good forSub-brands, client themes, white-labellingLight/dark, high contrast, dimmed
Already set up light and dark as brands? That still works, but your app then needs a different stylesheet for each and cannot follow the OS preference. Moving to modes means one stylesheet and a data-theme switch. The two axes combine: every brand gets every mode.

Set it up

  1. Declare the modes in spektral.yml

    Add a modes block under tokens in the spektral.yml at your project root. Create the file if the project does not have one. Keep anything else already in it.

    # spektral.yml
    tokens:
      modes:
        contexts: [light, dark]
        default: light
        layers: [semantic]

    default is the mode your existing files already are, so nothing about the default changes: a project with this block and no overlays builds exactly as before. layers names where dark values are allowed. Semantic is the right answer for almost every system (see why).

  2. Reopen the project

    Spektral reads spektral.yml when a project opens. Open it again from the welcome screen, and the Tokens page gains a Mode selector beside Brand.

  3. Override the tokens that change

    On the Tokens page pick Mode: dark and the Semantic tab. Every row starts out badged Inherited, showing the light value. Edit one and it becomes Overridden. The reset action on a row removes the override, so the token falls back to light again.

    Surfaces, text, borders and the "subtle" background tints usually need overriding. Status colours and spacing usually do not.

  4. Build and preview

    Press Build. The Storybook panel beside the grid follows the mode you are editing, so you can check contrast as you go. In Storybook itself, a Theme toolbar switches every story between light and dark.

The files

The token manager writes a file next to the one it overlays. You can also write it by hand. It is ordinary DTCG JSON.

tokens/
  default/
    primitive.tokens.json        # the palette: every swatch, light and dark
    semantic.tokens.json         # light values (the default mode)
    semantic.dark.tokens.json    # dark overrides only
    component.tokens.json        # no overlay needed
spektral.yml

The overlay is sparse

semantic.dark.tokens.json contains only the tokens whose value differs in dark mode. Everything it leaves out is inherited from semantic.tokens.json. A missing overlay file just means dark mode changes nothing yet.

{
  "color": {
    "$type": "color",
    "surface":          { "$value": "{color.neutral.100}" },
    "surface-raised":   { "$value": "{color.neutral.90}" },
    "on-surface":       { "$value": "{color.neutral.10}" },
    "on-surface-muted": { "$value": "{color.neutral.40}" },
    "primary":          { "$value": "{color.navy.300}" },
    "border":           { "$value": "{color.neutral.70}" }
  }
}

An overlay can only redefine tokens. It cannot add them: a mode that defined color.glow when light mode has no such token would leave components with a variable that exists in one mode only. Tokens like that are skipped, and the token build log lists them so they do not disappear without a trace. Add the token to the base file first, then override it.

Why semantic, and not primitive or component

What the build produces

The build runs once per mode and appends each non-default mode to the same variables.css. The property names never change, only their values, so no component needs to know modes exist.

:root {
  /* every token, in the default mode */
  --color-surface: var(--color-neutral-white);
  --color-on-surface: var(--color-neutral-100);
  /* … */
}

:root {
  color-scheme: light dark;
}

/* Mode: dark */
[data-theme='dark'] {
  --color-surface: var(--color-neutral-100);
  --color-on-surface: var(--color-neutral-10);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --color-surface: var(--color-neutral-100);
    --color-on-surface: var(--color-neutral-10);
  }
}

That gives three states. With no data-theme attribute, the page follows the operating system. data-theme="dark" forces dark. data-theme="light" forces light, and the :not([data-theme]) guard stops the OS preference from overriding it. With a cssPrefix set, the names carry it as usual (--ds-color-surface).

Use it in your app

Import the library's stylesheet as you already do. Dark mode then works with no code at all: users whose OS is set to dark get dark. A light/dark/system switch takes one attribute:

// 'light' | 'dark' pin a mode; 'system' hands control back to the OS.
export function setTheme(choice) {
  const root = document.documentElement;
  if (choice === 'system') {
    root.removeAttribute('data-theme');
  } else {
    root.setAttribute('data-theme', choice);
  }
  try { localStorage.setItem('theme', choice); } catch {}
}

Restore the saved choice before the first paint, or a returning dark-mode user sees a flash of light:

<!-- In <head>, before any stylesheet paints: no flash of the wrong theme -->
<script>
  try {
    const saved = localStorage.getItem('theme');
    if (saved === 'light' || saved === 'dark') {
      document.documentElement.setAttribute('data-theme', saved);
    }
  } catch {}
</script>

More than light and dark

Modes are not limited to two, and the selector is yours to choose. Every field, with its default:

tokens:
  modes:
    contexts: [light, dark, high-contrast]   # at least two
    default: light                           # the mode the base files are
    layers: [semantic]                       # layers that may carry overlays
    file: "{layer}.{mode}.tokens.json"       # overlay filename in the brand dir
    css:
      selector: "[data-theme='{mode}']"      # scopes a non-default mode
      mediaQuery: true                       # also follow prefers-color-scheme

Tokens driven by a DTCG resolver

If your token tree has a DTCG resolver, you may not need a modes block at all. The resolver's first modifier becomes the brand axis and its second becomes the mode axis. The second modifier's default context is the base, and each other context's first source is that mode's overlay.

{
  "version": "2025.10",
  "sets": {
    "primitive": { "sources": [{ "$ref": "primitive.tokens.json" }] }
  },
  "modifiers": {
    "brand": {
      "default": "acme",
      "contexts": {
        "acme":   [{ "$ref": "acme/semantic.tokens.json" }],
        "globex": [{ "$ref": "globex/semantic.tokens.json" }]
      }
    },
    "mode": {
      "default": "light",
      "contexts": {
        "light": [],
        "dark":  [{ "$ref": "semantic.dark.tokens.json" }]
      }
    }
  }
}

A resolver whose only modifier is light/dark reads it as brands, because the first modifier is always the brand axis. To treat it as modes instead, declare a modes block: a hand-written block always wins over what the resolver implies.

Going the other way, every brand Spektral owns gets a generated tokens.resolver.json with a mode modifier on each build. Other DTCG tools can then resolve the same light and dark permutations. It is output only: edit spektral.yml, not that file.

Your own token build

If spektral.yml declares a tokens.build command, Spektral runs it instead of its own Style Dictionary build and passes SPEKTRAL_MODES (comma-separated, e.g. light,dark) and SPEKTRAL_DEFAULT_MODE in the environment. List every stylesheet it emits under builtCss, so CSS validation knows about them. See the spektral.yml reference.

Current limits

New to the layers? Start with Edit your tokens and the token architecture reference.