How to Switch Dark Mode & Brand Themes at Runtime with a Single Style Dictionary `outputReferences` Option
Meta description: A practical pattern for switching dark mode and multi-brand themes at runtime using only Semantic layer overrides, by combining Style Dictionary v4's
outputReferences: trueoption with CSS Custom Properties. Familiarity with CSS variables and JSON is all you need.
There's a wall every design system builder inevitably hits: "How do I manage dark mode?" And if you're building a B2B product (a white-label product branded per client), that becomes: "Do I also need to manage per-brand themes separately?" At first it seems simple enough to just maintain two CSS files, but the moment your token count exceeds a few hundred, you realize how nightmarish that approach is. I've been there. After manually copying 237 tokens across two files and watching them fall out of sync, I knew something was fundamentally wrong.
This article covers how to solve this problem elegantly using Style Dictionary's outputReferences option combined with CSS Custom Properties. This single pattern eliminates three problems: first, duplicate token declarations for dark mode and brand themes; second, sync drift between theme files; and third, large-scale modifications every time a new theme is added. We'll walk through production-ready code that demonstrates a structure where a single token pipeline handles dark mode and brand theme switching at runtime, while minimizing override targets to the Semantic layer.
Core Concepts
Why outputReferences Is the Key to Theme Switching
Style Dictionary is a tool that transforms design tokens written in JSON into platform-specific code such as CSS, Swift, and XML. Its default behavior resolves all token aliases (references) and outputs fully expanded final values. But setting outputReferences: true changes that behavior.
// tokens/color.tokens.json
{
"color": {
"red": { "$value": "#ff0000", "$type": "color" },
"danger": { "$value": "{color.red}", "$type": "color" }
}
}/* outputReferences: false (default) — references are resolved and expanded to values */
:root {
--color-red: #ff0000;
--color-danger: #ff0000;
}
/* outputReferences: true — the var() reference chain is preserved */
:root {
--color-red: #ff0000;
--color-danger: var(--color-red);
}Why does the second approach matter? Because CSS Custom Properties are computed at browser runtime. If --color-danger references var(--color-red), then changing --color-red in a dark mode CSS file is all it takes for --color-danger to update automatically. This cascade is the core mechanism behind runtime theme switching.
The 3-Layer Token Structure: Primitive → Semantic → Component
If you're new to the design token layer concept: Primitives are raw palette colors, Semantics assign meaning to those colors, and Components represent context for individual UI elements.
Honestly, three layers felt like overkill at first — but once I was dealing with multiple themes, I immediately realized it's impossible without this structure.
| Layer | Example | Role |
|---|---|---|
| Primitive | --color-red-500: #ef4444 |
Holds the raw value |
| Semantic | --color-danger: var(--color-red-500) |
Assigns meaning (danger) |
| Component | --button-bg: var(--color-danger) |
Applies component context |
When switching to dark mode, only the Semantic layer needs to change. When --color-danger changes to var(--color-green-400), the Component layer's --button-bg automatically receives the updated value. Primitive and Component layers require no changes.
You might wonder why [data-theme="dark"] beats :root — it's CSS specificity. :root is an element selector (0-0-1), whereas [data-theme="dark"] is an attribute selector (0-1-0) with higher priority. This ensures that re-declaring the same variable name safely overrides with the dark mode value.
The W3C DTCG Spec and Style Dictionary v4
The W3C Design Tokens Community Group has published the first stable version of the design token specification. The .tokens.json extension and the $value/$type keys are now official, and Style Dictionary v4 supports this format natively.
// DTCG format (.tokens.json)
{
"color": {
"red": {
"$value": "#ff0000",
"$type": "color"
},
"danger": {
"$value": "{color.red}",
"$type": "color"
}
}
}A particularly notable change in v4 is that outputReferences now supports a function form. While the basic form is a true/false boolean, the function form has a signature that lets you conditionally decide whether to output references per token. outputReferencesFilter is an official helper utility that leverages this function form, automatically handling the broken reference problem that occurs when a filtered token is referenced. This difference is covered in detail in Example 3.
Now let's apply these concepts to real code.
Practical Application
Example 1: Dark Mode — Minimal Override Pattern
This is the most fundamental and widely used pattern in production. Light tokens are placed under :root and dark tokens under a [data-theme="dark"] selector, overriding only the Semantic layer.
When I first introduced this pattern, I made the mistake of re-declaring Primitive tokens in the dark file too. --color-red-500 is identical in both light and dark, yet I had it in both files. The key is keeping it in a single global.json and only overriding Semantics.
Sample token files:
// tokens/global.json — Primitive tokens (shared by light and dark)
{
"color": {
"red": { "$value": "#ef4444", "$type": "color" },
"green": { "$value": "#22c55e", "$type": "color" },
"gray": {
"900": { "$value": "#111827", "$type": "color" },
"100": { "$value": "#f3f4f6", "$type": "color" }
},
"white": { "$value": "#ffffff", "$type": "color" }
}
}// tokens/light.json — Semantic tokens (light mode defaults)
{
"color": {
"bg": { "$value": "{color.white}", "$type": "color" },
"text": { "$value": "{color.gray.900}", "$type": "color" },
"danger": { "$value": "{color.red}", "$type": "color" }
}
}// tokens/dark.json — Semantic tokens (only what changes)
{
"color": {
"bg": { "$value": "{color.gray.900}", "$type": "color" },
"text": { "$value": "{color.white}", "$type": "color" }
}
}Token file structure:
tokens/
├── global.json # Primitive tokens (shared)
├── light.json # Light mode Semantic tokens
└── dark.json # Dark mode Semantic tokens (only what changes)Style Dictionary configuration:
// style-dictionary.config.js (v4, ES Modules)
export default {
source: ['tokens/global.json', 'tokens/light.json', 'tokens/dark.json'],
platforms: {
css: {
transformGroup: 'css',
files: [
{
destination: 'dist/variables.css',
format: 'css/variables',
filter: token => !token.filePath.includes('dark'),
options: {
outputReferences: true,
selector: ':root',
},
},
{
destination: 'dist/variables-dark.css',
format: 'css/variables',
filter: token => token.filePath.includes('dark'),
options: {
outputReferences: true,
selector: '[data-theme="dark"], .dark',
},
},
],
},
},
};Does
filterexclude tokens automatically? — Yes, it does. Tokens that don't match thefilter: token => token.filePath.includes('dark')condition are simply not output by Style Dictionary in that file. Tokens like--color-dangerthat are identical in dark mode are automatically excluded without any manual removal.
Output:
/* dist/variables.css */
:root {
--color-red: #ef4444;
--color-gray-900: #111827;
--color-white: #ffffff;
/* Semantic — light mode defaults */
--color-bg: var(--color-white);
--color-text: var(--color-gray-900);
--color-danger: var(--color-red);
}
/* dist/variables-dark.css */
[data-theme="dark"], .dark {
/* Only Semantics are overridden — Primitives are reused as-is */
--color-bg: var(--color-gray-900);
--color-text: var(--color-white);
/* --color-danger is identical, so it's automatically excluded */
}| Point | Description |
|---|---|
filter: token => token.filePath.includes('dark') |
Selects only dark-specific tokens to minimize file size |
outputReferences: true on both sides |
The var() chain is preserved in both files |
| No duplicate Primitives | The dark file contains only the Semantics that differ |
Runtime theme toggle JavaScript:
// theme.js
const toggleTheme = (theme) => {
if (theme === 'dark') {
// Dark: add attribute → activates the [data-theme="dark"] selector
document.documentElement.setAttribute('data-theme', 'dark');
} else {
// Light: remove attribute → falls back to :root defaults
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme', theme);
};
// Initial load: detect system preference + apply user override
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
const savedTheme = localStorage.getItem('theme') ?? (prefersDark.matches ? 'dark' : 'light');
toggleTheme(savedTheme);
// Detect system preference changes
prefersDark.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
toggleTheme(e.matches ? 'dark' : 'light');
}
});Why not
setAttributefor thelightvalue? Since the only CSS selector is[data-theme="dark"], settingdata-theme="light"has no effect. It's cleaner to remove the attribute in light mode and naturally fall back to the:rootdefaults.
Example 2: Multi-Brand Theme Pipeline
For B2B SaaS or white-label products (separate products branded per client), you may need to maintain completely different themes per brand. This pattern handles brand × mode combinations via a build loop.
Token structure:
tokens/
├── global/ # Shared Primitives (all brands)
│ └── color.json
├── brand-a/ # Brand A Semantic tokens
│ ├── light.json
│ └── dark.json
└── brand-b/ # Brand B Semantic tokens
├── light.json
└── dark.jsonBuild script:
// build.js
import StyleDictionary from 'style-dictionary';
const brands = ['brand-a', 'brand-b'];
const modes = ['light', 'dark'];
// When brands are added, just add an entry to the selectors object
const selectors = {
'brand-a': '.brand-a',
'brand-b': '.brand-b',
};
for (const brand of brands) {
for (const mode of modes) {
const sd = new StyleDictionary({
source: [
'tokens/global/**/*.json',
`tokens/${brand}/${mode}.json`,
],
platforms: {
css: {
transformGroup: 'css',
files: [{
destination: `dist/${brand}-${mode}.css`,
format: 'css/variables',
options: {
outputReferences: true,
selector: selectors[brand],
},
}],
},
},
});
await sd.buildAllPlatforms();
}
}Applying themes in HTML:
<!-- Brand class on root, data-theme for mode switching -->
<html class="brand-a">
<head>
<!-- Load light as default, add dark as override -->
<link rel="stylesheet" href="dist/brand-a-light.css">
<link rel="stylesheet" href="dist/brand-a-dark.css"
media="(prefers-color-scheme: dark)">
</head>
</html>Why load both files: If you only link
brand-a-dark.css, the light variables simply don't exist when in dark mode. It's safer to load light as the base and override with dark via amediacondition or JavaScript.
Example 3: Conditional outputReferences Function (v4 Only)
This is a situation frequently encountered in production: when a filter excludes certain tokens, other tokens referencing them can output broken references like var(--undefined).
In v4, outputReferences accepts not just a boolean but also a function signature:
// Function signature
outputReferences: (token: TransformedToken, options: { dictionary: Dictionary }) => booleanoutputReferencesFilter is an official helper that leverages this function form. It automatically determines "has the token this one references been removed by the filter?" to prevent broken references.
import StyleDictionary, { outputReferencesFilter } from 'style-dictionary';
export default {
platforms: {
css: {
files: [{
destination: 'dist/variables.css',
format: 'css/variables',
filter: token => token.attributes.category !== 'internal',
options: {
// When referencing a filtered token, use the resolved value instead of var()
outputReferences: (token, { dictionary }) => {
return outputReferencesFilter(token, { dictionary });
},
},
}],
},
},
};Example 4: Tokens Studio + sd-transforms Integration
The full pipeline of Figma Variables → Tokens Studio → Style Dictionary has become something of an industry standard.
// style-dictionary.config.js
import StyleDictionary from 'style-dictionary';
import { register } from '@tokens-studio/sd-transforms';
// Register Tokens Studio-specific transforms
register(StyleDictionary);
export default {
source: [
'tokens/$metadata.json',
'tokens/**/*.json',
],
preprocessors: ['tokens-studio'], // Preprocess Tokens Studio format
platforms: {
css: {
transformGroup: 'tokens-studio', // Use the registered transform group
files: [{
destination: 'dist/variables.css',
format: 'css/variables',
options: { outputReferences: true },
}],
},
},
};Pros and Cons
Advantages
| Item | Detail |
|---|---|
| Single source of truth | One JSON token file generates output for all platforms simultaneously — CSS, iOS, Android, JS, etc. |
| Minimal overrides | Dark mode and brand switching by overriding only the Semantic layer — minimal CSS file size |
| Automatic reference chain resolution | var() chains are automatically resolved at runtime in the browser |
| Scalability | Adding a new brand theme = adding one JSON file plus one entry in the build loop object |
| Designer–developer collaboration | Automated pipeline: Figma Variables → Tokens Studio → Style Dictionary |
Disadvantages and Caveats
| Item | Detail | Mitigation |
|---|---|---|
| Initial setup complexity | Multi-brand + multi-mode combinations are hard to implement from the official docs alone | Use Example 2 in this article as a template |
| Filter + outputReferences conflict | Risk of var(--undefined) output when referencing filtered tokens |
Apply the v4 outputReferencesFilter utility |
light-dark() generation limitations |
Conflicts with DTCG mode structure; difficult to access bi-directional values from a single token | Write a custom formatter or evaluate Terrazzo |
| Bundle size growth | CSS file count grows as brand × mode combinations increase | Design a conditional loading strategy using <link media> or dynamic import |
| Build time | Potential build delays with thousands of tokens and multiple platforms | Apply incremental builds or per-brand parallel builds |
CSS
light-dark()function — A native CSS function that declares light/dark values in a single line:color: light-dark(#000, #fff). Supported in all major browsers, but integrating it with Style Dictionary's DTCG mode structure requires a custom formatter that can access both light and dark values simultaneously.
CSS
color-schemeproperty — Declaring:root { color-scheme: light dark; }makes the browser automatically render native UI elements such as scrollbars and form controls in dark mode as well. Recommended for use alongside theoutputReferencespipeline.
The Most Common Production Mistakes
-
Re-declaring Primitive tokens in the dark file —
--color-red-500is identical in both light and dark, so keep it only inglobal.jsonand override only Semantics. Duplicate declarations just bloat file size. -
Forgetting
outputReferences— Without this option, all references are expanded to final values, and changing a root variable in the dark CSS triggers no cascade. This is the most common reason theme switching doesn't work at all. -
The whole team not following the 3-layer rule — If someone references a Primitive directly from a Component token (
--button-bg: var(--color-red-500)), that button alone won't receive dark mode theming. It's recommended to enforce the convention of always going through Semantics via team lint rules or review checklists.
Closing Thoughts
To summarize what we've covered, three things are key: preserve the var() reference chain with outputReferences: true, separate tokens into Primitive, Semantic, and Component layers, and put only the changing Semantics in the dark file. When these three click together, adding a new theme becomes as simple as adding one JSON file.
Keeping the token reference chain alive with a single outputReferences: true is all it takes to make dark mode and brand theme switching complete with just Semantic layer overrides. This structure shines even more at token counts in the thousands. A basic pipeline can be up and running in half a day, and from that point on, you just add JSON files.
Three steps you can start right now:
-
Install Style Dictionary v4 and set up the basic structure — Install with
pnpm add -D style-dictionary, then start by writing two files:tokens/global.json(Primitives) andtokens/light.json(Semantics). Starting with DTCG format ($value,$type) from the beginning is recommended — it eliminates format conversion costs when later integrating with Tokens Studio. -
Confirm CSS variable file generation with the
outputReferences: trueoption — After building, verify that references in the formvar(--color-xxx)are alive in the output CSS. If they're expanded to values, the option isn't being applied. -
Add
tokens/dark.jsonand generate dark mode CSS — Define only the changing Semantic tokens indark.json, usefilterto select only those tokens, and output them under the[data-theme="dark"]selector. Toggling thedata-themeattribute should confirm the theme switches correctly.
If you hit any roadblocks, try asking in the Style Dictionary official Discord or GitHub Discussions — the community tends to respond quite quickly.
Next article: We'll cover how to write a Style Dictionary custom formatter to auto-generate the CSS
light-dark()function and integrate it with the DTCG modes structure.
References
- Dark Mode with Style Dictionary | dbanks design
- Implementing Light and Dark Mode with Style Dictionary (Part 1) | Always Twisted
- Implementing Light and Dark Mode with Style Dictionary (Part 2) | Always Twisted
- Formats | Style Dictionary Official Docs
- References | Style Dictionary Official Docs
- Migration Guidelines v4 | Style Dictionary
- Examples | Style Dictionary
- Style Dictionary V4 release plans | Tokens Studio
- sd-transforms | Tokens Studio GitHub
- Design Tokens specification reaches first stable version | W3C Community Group
- The developer's guide to design tokens and CSS variables | Penpot
- Output References | DeepWiki amzn/style-dictionary
- Advanced Theming Techniques with Design Tokens | Medium
- Building a Scalable Design Token System | Medium