Search
Search
How the JSON token sources are compiled into CSS custom properties, including the dark theme pass.
Style Dictionary v5 transforms the DTCG JSON token files into CSS custom properties.
npm run build
Output is written to dist/css/. One CSS file is generated per JSON source file, preserving the same directory structure:
tokens/primitive/color.json → dist/css/primitive/color.css
tokens/semantic/color.json → dist/css/semantic/color.css
style-dictionary.config.jsThe config is a single ES module. It registers a custom name transform and a custom format, then maps every JSON file to a CSS output.
name/kebab/strip-defaultConverts the token path to a kebab-case CSS custom property name, dropping any default segment:
color.background.default → --color-background
color.text.muted → --color-text-muted
css/layer-tokensAll tokens are wrapped in @layer tokens { :root { … } }. This is a low-priority layer in the Un Cinq stack (order: reset, tokens, base, layouts, vendors, components), so any project can override any token by importing after this package inside its own @layer tokens block.
References are preserved as var() — tokens are not resolved to their final values:
/* output */
--color-text: var(--color-gray-900); /* not oklch(0.208 0.006 264.542) */
This is done by reading token.original.$value and replacing every {path.to.token} reference with var(--path-to-token). References embedded in a string value are also handled:
{ "$value": "{size.8} {size.12}" }
--file-button-padding: var(--size-8) var(--size-12);
Tokens whose $value is an object or an array of objects (e.g. shadow) are serialized as a space-separated CSS value. References inside the composite are converted to var().
Property order matters — list properties in the order CSS expects them. For box-shadow:
{
"shadow": {
"sm": {
"$type": "shadow",
"$value": [
{
"offsetX": "0",
"offsetY": "1px",
"blur": "2px",
"spread": "0",
"color": "{color.shadow.light}",
"inset": false
}
]
}
}
}
--shadow-sm: 0 1px 2px 0 var(--color-shadow-light);
inset is always treated as a boolean prefix (inset or nothing), not a positional value.
themes/dark.jsonDark mode is a separate token set, tokens/themes/dark.json — not declared inline on tokens (there is no $mods/$modes extension).
The model is default + override:
semantic layer carries the default (light) values, at their natural place (semantic/color.json, semantic/form.json). There is no themes/light.json — light is the baseline, not a mode you switch into.themes/dark.json holds only the tokens that differ in dark mode (background, text, heading, shadow, border, form background). Everything mode-invariant (brand, accent, status, …) is inherited automatically through var().This mirrors the DTCG Resolver philosophy (a base set + an overlay holding only the differences), so it stays forward-compatible as the Resolver spec stabilises.
The build emits dist/css/themes/dark.css with a single rule, inside @layer tokens:
@layer tokens {
@media (prefers-color-scheme: dark) {
:root:not([data-color-scheme="light"]) {
--color-background: var(--color-gray-950);
/* … */
}
}
}
The light defaults stay in dist/css/semantic/*.css on :root. The dark file is purely additive — a light-only (mono-mode) site never needs to load it.
Dark mode follows the OS preference. The only attribute that does anything is data-color-scheme="light", which forces light even when the OS prefers dark. Forcing dark against a light OS is intentionally not supported (there is no [data-color-scheme="dark"] rule).
data-color-scheme on <html> | OS preference | Result | Why |
|---|---|---|---|
| (none) | light | light | :root defaults; the media query is inactive |
| (none) | dark | dark | the media rule matches :root (not forced light) |
light | dark | light | :not([data-color-scheme="light"]) excludes it → :root defaults |
light | light | light | :root defaults; the media query is inactive |
| (any other) | dark | dark | the media rule still matches — only light opts out |
Note —
color-scheme: darkis intentionally absent from the generated block. Thecolor-schemeproperty is managed at the HTML level by the consuming application (e.g. via<meta name="color-scheme">), not by the token layer.
themes/dark.json; everything mode-invariant stays in semantic.{dotted.path} reference syntax as $value (e.g. {color.gray.950}).tokens/ with DTCG structure.npm run build — the file is detected automatically.dist/css/.No changes to style-dictionary.config.js are needed.
clamp() values in spacing and typography tokensAlongside the CSS, the build writes dist/tokens.json: a flat array of every token the package ships.
[
{
"name": "--color-brand",
"value": "var(--color-sienna-600)",
"type": "color",
"file": "semantic/color",
"description": ""
}
]
The documentation site renders its reference tables from this file, which is why the reference cannot drift from the stylesheets.
The guarantee comes from a single shared function. tokenToCssValue() serializes a token to its CSS value, and both the CSS format and the manifest format call it. There is no second implementation of the naming or the value logic to fall out of step, and the build asserts the equivalence: the manifest holds exactly one entry per declaration emitted in dist/css/, with the same name and the same value.
description comes from the DTCG $description key. Adding one to a token source makes it appear in the published reference with no other change.