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

  • 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)

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

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)

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

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)

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

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

After running all three builds, confirm parity:

# 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

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

Troubleshooting

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

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.