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.
| Brand | Mode | |
|---|---|---|
| Chosen by | You, at build time | The user or their OS, at runtime |
| On disk | A full set of layer files | A sparse overlay: only what changes |
| Output | A separate stylesheet | Extra rules appended to the same stylesheet |
| Good for | Sub-brands, client themes, white-labelling | Light/dark, high contrast, dimmed |
data-theme switch. The two axes combine: every brand gets every mode.Set it up
- Declare the modes in spektral.yml
Add a
modesblock undertokensin thespektral.ymlat 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]defaultis 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.layersnames where dark values are allowed. Semantic is the right answer for almost every system (see why). - Reopen the project
Spektral reads
spektral.ymlwhen a project opens. Open it again from the welcome screen, and the Tokens page gains a Mode selector beside Brand. - 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.
- 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.ymlThe 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
- Primitives stay the same in every mode. The palette holds every swatch, dark ones included. Dark mode is a decision that
surfacepoints at a different swatch. It is not a second palette. Spektral refuses an overlay on a shared layer such as primitives, and says why in the token manager. - Components follow on their own.
button.bg → {color.primary}flips whencolor.primarydoes, because references are kept in the built CSS. You do not need component overlays. You can add one tolayersif a single component truly needs different treatment in dark mode.
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- Extra modes such as
high-contrastget their own overlay (semantic.high-contrast.tokens.json) and their own selector block. Onlylightanddarkget theprefers-color-schememedia query, since those are the only two values it knows. - A class-based selector like
".theme-{mode}"works if your app already toggles a class. A selector Spektral cannot invert into a "no explicit choice" guard, such as"html.{mode} body", gets no media query. Otherwise it would override the user's explicit choice. mediaQuery: falseturns off OS-following entirely, so dark mode applies only when you set the attribute.
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
- The agent and cloud sync see the default mode only. Agent token edits and synced token changes apply to the light values. Edit dark overrides on the Tokens page.
- The Storybook Theme toolbar lists light and dark. It does not read
spektral.ymlyet. To preview an extra mode, add it to theTHEMESlist in.storybook/preview.ts. - Build from the app, not with a bare
npm run build:tokens. Style Dictionary's*.tokens.jsonglob cannot tellsemantic.dark.tokens.jsonfrom the base file, so Spektral stages each mode's input before building. Running the script directly in a brand directory that holds overlays would mix light and dark together.
New to the layers? Start with Edit your tokens and the token architecture reference.