Style Dictionary vs Theo vs Cobalt: Token Compiler Comparison

Part of Token Compiler Comparison. This page runs the same small token set through Style Dictionary, Theo, and Cobalt (cobalt-ui) side-by-side so you can see exactly what each tool produces, where each one wins, and which fits your CI constraints.

One token source fanning out through three compilers A single tokens.json file on the left fans out through Style Dictionary, Theo, and Cobalt in the centre, each producing a CSS variables file on the right. tokens.json (DTCG format) shared source Style Dictionary sd.config.js v4 · DTCG via parser Theo theo.config.js Salesforce · YAML/JSON Cobalt (cobalt-ui) tokens.config.js native DTCG · plugins variables.css + Android / iOS multi-platform variables.css + Sass / JS transform-centric variables.css + JSON / TS types spec-first
The same DTCG token source runs through Style Dictionary, Theo, and Cobalt independently, each emitting its own CSS variables file and optional secondary outputs.

Prerequisites Link to this section

  • Node.js 20 LTS or later installed.
  • npm init -y in a fresh directory — all three tools are installed independently, not together.
  • Familiarity with the W3C Design Tokens Community Group (DTCG) $value/$type/$description key convention.
  • A CI runner (GitHub Actions examples shown) with actions/setup-node@v4 at Node 20.
  • Style Dictionary v4.x (npm install -D style-dictionary@^4), Theo 11.x (npm install -D @salesforce-ux/theo@^11), cobalt-ui (npm install -D @cobalt-ui/cli @cobalt-ui/plugin-css).

The shared token file used throughout every step is:

{
  "color": {
    "action": {
      "primary": {
        "$value": "#0055ff",
        "$type": "color",
        "$description": "Primary interactive color"
      },
      "hover": {
        "$value": "#0040cc",
        "$type": "color"
      }
    },
    "neutral": {
      "background": {
        "$value": "#f1f5f9",
        "$type": "color"
      }
    }
  },
  "spacing": {
    "base": {
      "$value": "16px",
      "$type": "dimension"
    },
    "large": {
      "$value": "24px",
      "$type": "dimension"
    }
  }
}

Save this as tokens/tokens.json. Every tool reads this exact file.


Step 1 — Style Dictionary (v4) Link to this section

Intent: compile the token set using Style Dictionary’s built-in DTCG parser (introduced in v4), emit CSS custom properties, and export a secondary Android XML file to illustrate multi-platform output.

// sd.config.js
import StyleDictionary from 'style-dictionary';
import { useDtcg } from 'style-dictionary/utils';

// Tell SD to treat $value/$type as the DTCG convention
const sd = new StyleDictionary({
  preprocessors: ['tokens-studio'],
  parsers: [useDtcg()],
  source: ['tokens/**/*.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      buildPath: 'dist/sd/',
      files: [{
        destination: 'variables.css',
        format: 'css/variables',
        options: { outputReferences: true, selector: ':root' }
      }]
    },
    android: {
      transformGroup: 'android',
      buildPath: 'dist/sd/android/',
      files: [{
        destination: 'tokens.xml',
        format: 'android/resources'
      }]
    }
  }
});

await sd.buildAllPlatforms();

Run with: node sd.config.js

Generated CSS output (dist/sd/variables.css):

/**
 * Do not edit directly, this file was auto-generated.
 */
:root {
  --color-action-primary: #0055ff;
  --color-action-hover: #0040cc;
  --color-neutral-background: #f1f5f9;
  --spacing-base: 16px;
  --spacing-large: 24px;
}

Why this works: Style Dictionary’s useDtcg() parser strips the $ prefix from keys and maps $value to the internal value field before transforms run. The css transform group applies color/css (passes hex through), size/px (appends px if numeric), and name/kebab (path segments joined with -). The result is deterministic: the token path color.action.primary becomes --color-action-primary with no extra configuration.

Custom transform example Link to this section

When you need rem output instead of px, register a transform before building:

StyleDictionary.registerTransform({
  name: 'size/rem',
  type: 'value',
  filter: token => token.$type === 'dimension',
  transform: token => {
    const px = parseFloat(token.$value);
    return `${px / 16}rem`;
  }
});

Add 'size/rem' to a custom transforms array in the platform config. This is Style Dictionary’s primary extension point — the transform registry is global and composable.


Step 2 — Theo (Salesforce) Link to this section

Intent: run the same token set through Theo’s transform pipeline. Theo uses its own JSON format natively; the DTCG file needs a thin wrapper or direct mapping. Theo 11 accepts JSON with global.props or flat props at the top level.

Theo does not parse DTCG $value/$type natively, so create a lightweight adapter script that converts the shared token file to Theo’s expected shape:

// scripts/to-theo.mjs
import { readFileSync, writeFileSync } from 'fs';

const dtcg = JSON.parse(readFileSync('tokens/tokens.json', 'utf8'));
const props = {};

function flatten(obj, path = []) {
  for (const [key, node] of Object.entries(obj)) {
    if ('$value' in node) {
      const name = [...path, key].join('-').toUpperCase().replace(/-/g, '_');
      props[name] = {
        value: node.$value,
        type: node.$type === 'dimension' ? 'size' : node.$type,
        category: path[0] ?? 'global'
      };
    } else {
      flatten(node, [...path, key]);
    }
  }
}

flatten(dtcg);
writeFileSync('tokens/theo-tokens.json', JSON.stringify({ global: { props } }, null, 2));

Run: node scripts/to-theo.mjs to produce tokens/theo-tokens.json, then configure Theo:

// theo.config.js
const theo = require('@salesforce-ux/theo');
const { readFileSync, writeFileSync } = require('fs');
const path = require('path');

theo.convert({
  transform: {
    type: 'web',
    file: path.resolve('tokens/theo-tokens.json')
  },
  format: {
    type: 'custom-properties.css'
  }
}).then(css => {
  writeFileSync('dist/theo/variables.css', css);
  console.log('Theo: wrote dist/theo/variables.css');
});

Run with: node theo.config.js

Generated CSS output (dist/theo/variables.css):

/* Do not edit this file directly, it was generated by Theo */
:root {
  --COLOR-ACTION-PRIMARY: #0055ff;
  --COLOR-ACTION-HOVER: #0040cc;
  --COLOR-NEUTRAL-BACKGROUND: #f1f5f9;
  --SPACING-BASE: 16px;
  --SPACING-LARGE: 24px;
}

Why this works: Theo maps each props entry to a CSS custom property using SCREAMING_SNAKE_CASE by default with the custom-properties.css format. The web transform type applies Theo’s built-in value conversions (color pass-through, size → pixel string). The adapter script is the only mandatory glue; once Theo has its own JSON structure, its own build is zero-config.

Note on SCREAMING_SNAKE_CASE Link to this section

Theo’s default naming convention differs from the kebab-case most CSS codebases use. You can supply a custom format handler to emit lowercase kebab names, but this requires writing a format plugin — roughly 15 lines of JavaScript. For teams already using Theo in a Salesforce-descended design system, the naming convention is a non-issue. For greenfield projects, the friction is real.


Step 3 — Cobalt (cobalt-ui) Link to this section

Intent: run the same token file through cobalt-ui with zero adapter code. Cobalt is built from the ground up for the DTCG specification and reads $value/$type natively.

// tokens.config.js
import pluginCSS from '@cobalt-ui/plugin-css';

/** @type {import('@cobalt-ui/core').Config} */
export default {
  tokens: './tokens/tokens.json',
  outDir: './dist/cobalt/',
  plugins: [
    pluginCSS({
      selector: ':root',
      modeSelectors: {},
      // Generate a typed TypeScript module alongside CSS
      generateTypes: false
    })
  ]
};

Run with: npx co build

Generated CSS output (dist/cobalt/tokens.css):

/* [cobalt-ui] auto-generated — do not edit */
:root {
  --color-action-primary: #0055ff;
  --color-action-hover: #0040cc;
  --color-neutral-background: #f1f5f9;
  --spacing-base: 16px;
  --spacing-large: 24px;
}

Why this works: Cobalt reads the $type field and applies the correct serialisation for each token category without any transform registration. color tokens are emitted as hex strings (or oklch if you configure color.convertToColorSpace). dimension tokens preserve their original unit. The output property names follow the JSON key path in lowercase-kebab, matching Style Dictionary’s default — so CSS consumers see identical variable names when you migrate between these two tools.

Adding TypeScript types Link to this section

Setting generateTypes: true in the plugin config emits a tokens.d.ts file mapping every token to its TypeScript type. This is a Cobalt-only capability with no equivalent in Theo and only partial support in Style Dictionary via community plugins.


Step 4 — Verification Link to this section

After running all three builds, confirm parity:

A parity check between two compiler outputs Two compiled CSS files feed a normalise-and-sort step whose diff is the parity verdict, with a note that normalisation must strip banner comments and key ordering first. dist/sd/variables.css 5 declarations dist/cobalt/tokens.css 5 declarations normalise + sort strip comments diff — empty = parity non-empty fails the job Parity is only meaningful after normalisation — banner comments and key order differ between every pair of compilers and say nothing about whether consumers would see the same values.
Parity means the declarations match, not the files. Normalising first is what keeps the check from failing on a banner comment.
# Normalize whitespace and compare property declarations
grep "^  --" dist/sd/variables.css dist/theo/variables.css dist/cobalt/tokens.css

Expected output shows matching property-value pairs for Style Dictionary and Cobalt; Theo produces uppercase names unless you override the format. A CI check that diffs dist/sd/variables.css against dist/cobalt/tokens.css gives you a regression gate when switching compilers:

# .github/workflows/token-parity.yml
name: Token compiler parity
on: [pull_request]
jobs:
  parity:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: node sd.config.js && node theo.config.js && npx co build
      - name: Compare SD and Cobalt output
        run: |
          diff <(grep "^  --" dist/sd/variables.css | sort) \
               <(grep "^  --" dist/cobalt/tokens.css | sort) \
          && echo "Parity OK" || (echo "Parity drift detected" && exit 1)

This CI step is the same pattern used in Token Scaling, Validation & CI Pipelines for any automated quality gate: extract the signal, diff it, fail loudly on mismatch.


Verdict Matrix Link to this section

Need / symptom Recommended tool Reason
Greenfield project, DTCG tokens from Figma Cobalt (cobalt-ui) Native DTCG support, no adapter code, TypeScript types
Existing large Style Dictionary setup Style Dictionary v4 Upgrade path is in-place; DTCG parser is additive
Salesforce Lightning or SLDS-derived system Theo Already in the Salesforce ecosystem; format support is production-tested there
Multi-platform output (Android XML, iOS Swift) Style Dictionary Broadest platform coverage; largest plugin ecosystem
Strict W3C spec compliance in CI validation Cobalt Validates $type values against the spec; rejects invalid types at build time
Custom value transforms (rem, oklch, etc.) Style Dictionary Transform registry is the most mature and documented
TypeScript token type generation Cobalt First-class feature; not available in Theo, patchy in SD
Minimal config, fast onboarding Cobalt Zero adapter code for DTCG; npx co build reads the file directly
YAML token sources Theo Supports YAML natively; SD and Cobalt require JSON
A two-question decision tree for choosing a token compiler A decision tree that first asks whether Style Dictionary is already in use, then whether native platforms are shipped, resolving to one of three recommendations with a side note on where Theo still fits. Do you already ship Style Dictionary? Yes — stay, add the DTCG parser No — do you ship native platforms? Yes Style Dictionary for platform breadth No Cobalt for DTCG and typed output Theo belongs in one case an existing Salesforce-descended system where the naming convention is already established
Two questions settle it in most organisations. The existing investment matters more than any feature the alternatives add.

Troubleshooting Link to this section

Symptom Likely cause Fix
Style Dictionary emits empty :root {} DTCG $value keys not parsed — useDtcg() preprocessor missing Import and call useDtcg() in the parsers array before buildAllPlatforms()
Theo produces undefined for color values Adapter script passed $value instead of value in the props object Confirm the adapter maps node.$valuevalue (no $ prefix) in the Theo JSON
Cobalt throws Unknown token type A $type in your source file is not in the DTCG spec (e.g., "shadow" where the spec uses "shadow" with plural composite structure) Check the DTCG draft for the correct type string; Cobalt validates against the spec
Property names differ between SD and Cobalt Style Dictionary uses a custom name/ transform that changes casing Remove custom name transforms or explicitly register the same kebab strategy in SD
CI build times exceed 30 s All three compilers running sequentially on a large token tree Run SD, Theo, and Cobalt in parallel using concurrently or matrix CI jobs

Migration Note: Moving a Style Dictionary setup to DTCG and Cobalt Link to this section

Many teams have existing Style Dictionary repositories using the pre-DTCG value (no $) convention. The migration is low-risk when done in two phases.

Phase 1 — add the DTCG parser to Style Dictionary v4 without changing your token files. This lets you ship v4 with zero token-file changes and unblock the ecosystem tools that expect $value.

// sd.config.js (phase 1 — SD v4, existing value keys still work)
import StyleDictionary from 'style-dictionary';

const sd = new StyleDictionary({
  source: ['tokens/**/*.json'],
  // No useDtcg() yet — keep existing {value} convention
  platforms: { /* unchanged */ }
});
await sd.buildAllPlatforms();

Phase 2 — migrate token files to DTCG using a codemod. Run this script once per token file:

// scripts/migrate-to-dtcg.mjs
import { readFileSync, writeFileSync } from 'fs';

function migrate(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  const out = {};
  for (const [k, v] of Object.entries(obj)) {
    if (k === 'value')       { out['$value'] = v; }
    else if (k === 'type')   { out['$type'] = v; }
    else if (k === 'comment'){ out['$description'] = v; }
    else                     { out[k] = migrate(v); }
  }
  return out;
}

const file = process.argv[2];
const src = JSON.parse(readFileSync(file, 'utf8'));
writeFileSync(file, JSON.stringify(migrate(src), null, 2));
console.log(`Migrated ${file}`);

Run: find tokens -name '*.json' -exec node scripts/migrate-to-dtcg.mjs {} \;

After migration, add useDtcg() to sd.config.js and run npx co build in parallel. At this point both Style Dictionary and Cobalt read from the same source with no adapter code. Theo still requires the adapter script described in Step 2 above, which is why teams moving to DTCG-first pipelines tend to retire Theo rather than maintain the shim.

For context on how this fits into a broader sync workflow, the Figma to CSS variable sync pipeline guide covers the upstream extraction step that feeds whichever compiler you choose.