Style Dictionary

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.

Run the build

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

Config — style-dictionary.config.js

The 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 transform — name/kebab/strip-default

Converts 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

Format — css/layer-tokens

All 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);

Composite tokens

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.


Dark mode — themes/dark.json

Dark mode is a separate token set, tokens/themes/dark.jsonnot declared inline on tokens (there is no $mods/$modes extension).

The model is default + override:

  • The 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.

Generated output

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.

Activation matrix

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 preferenceResultWhy
(none)lightlight:root defaults; the media query is inactive
(none)darkdarkthe media rule matches :root (not forced light)
lightdarklight:not([data-color-scheme="light"]) excludes it → :root defaults
lightlightlight:root defaults; the media query is inactive
(any other)darkdarkthe media rule still matches — only light opts out

Notecolor-scheme: dark is intentionally absent from the generated block. The color-scheme property is managed at the HTML level by the consuming application (e.g. via <meta name="color-scheme">), not by the token layer.

Rules

  • Only colour tokens that actually change between modes belong in themes/dark.json; everything mode-invariant stays in semantic.
  • Dark values follow the same {dotted.path} reference syntax as $value (e.g. {color.gray.950}).

Adding a new token file

  1. Create a JSON file anywhere under tokens/ with DTCG structure.
  2. Run npm run build — the file is detected automatically.
  3. A matching CSS file is generated in dist/css/.

No changes to style-dictionary.config.js are needed.


References


The JSON manifest

Alongside 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.