Multi-Platform Token Output
Shipping design tokens once and consuming them correctly across web, iOS, Android, and beyond — without divergence, duplication, or platform-specific hacks.
8 min read
The full lesson
Design tokens only deliver on their promise when every platform speaks the same visual language. Define a token once in a single JSON file, and it should appear as a CSS custom property on the web, a Swift Color constant on iOS, a Kotlin object on Android, and a Flutter ThemeData entry — all generated from one source of truth.
Without a deliberate output strategy, teams end up maintaining separate hardcoded style files for each platform. Those files drift the moment a designer changes a hex value.
This lesson covers the architecture, tooling, and key decisions behind a solid multi-platform token pipeline — from authoring a DTCG-compliant token set to platform-specific transforms and automated delivery.
Why Platform Output Is the Hard Part
Writing tokens is the easy part. Delivering them correctly to five different consumers is where most systems break. Each platform differs in four key ways:
- Value syntax — CSS uses
oklch(60% 0.15 250). Swift usesUIColor(red:green:blue:alpha:)or a named asset. Android uses@color/token_namein XML orColor(0xFF...)in Jetpack Compose. - File format —
.cssand.scssfor web;.xcassetsfor iOS color assets;colors.xmlorTheme.ktfor Android;.dartfiles for Flutter. - Reference model — CSS custom properties resolve at runtime. Swift and Kotlin constants resolve at compile time. This difference changes how token aliases behave on each platform.
- Theming mechanism — CSS uses the cascade and custom properties. iOS uses
UITraitCollection. Android usesthemes.xml. Your token pipeline must produce outputs that plug into each of these natively.
The old approach was manual: a design systems engineer would maintain a colors.css, a colors.swift, and a colors.xml file and try to keep them in sync through discipline and code review. In practice, they drifted within weeks.
A modern pipeline treats platform output files as build artifacts — they are generated, not authored.
The W3C DTCG Token Format as the Single Source
The W3C Design Token Community Group (DTCG) spec defines a stable JSON structure. Every token has a $value and a $type, and groups can nest hierarchically. As of 2024–2025, this format has broad tooling support and is the right choice for any system starting fresh.
A minimal DTCG-compliant token file looks like this:
{
"color": {
"brand": {
"primary": {
"$value": "oklch(55% 0.2 264)",
"$type": "color"
},
"primary-subtle": {
"$value": "{color.brand.primary}",
"$type": "color"
}
},
"surface": {
"default": {
"$value": "{color.neutral.0}",
"$type": "color"
}
}
},
"spacing": {
"4": {
"$value": "16",
"$type": "dimension"
}
}
}
Key rules for DTCG-compliant authoring:
- Use
$valueand$type— the$-prefixed keys are the spec standard. Older tooling used barevalueandtypewithout the prefix. Migrate away from the older style. - Aliases use curly-brace dot-notation inside
$valuestrings (as shown in the code sample above). - Use OKLCH for colors. OKLCH is perceptually uniform, gamut-aware, and generates predictable tonal scales algorithmically. HSL and raw hex are outdated authoring choices.
- Store dimensions as unitless numbers with a
dimensiontype. The transform pipeline adds the correct unit for each platform.
Style Dictionary: Transform Architecture
Style Dictionary (by Amazon, actively maintained, v4 as of 2024) is the dominant open-source tool for multi-platform token output. Its core model has four steps:
- Load — read one or more JSON/DTCG token files.
- Transform — apply per-platform value transforms (for example, convert
oklch()torgba()for platforms that need legacy color formats, or convert logical units for iOS). - Format — serialize the transformed tokens into the target file format.
- Output — write to the configured output directories.
A minimal Style Dictionary v4 config for web, iOS, and Android:
// style-dictionary.config.mjs
export default {
source: ["tokens/**/*.json"],
platforms: {
css: {
transformGroup: "css",
prefix: "ds",
buildPath: "build/web/",
files: [
{
destination: "tokens.css",
format: "css/variables",
options: { outputReferences: true }
}
]
},
ios_swift: {
transformGroup: "ios-swift",
buildPath: "build/ios/",
files: [
{
destination: "DesignTokens.swift",
format: "ios-swift/class.swift",
className: "DesignTokens"
}
]
},
android: {
transformGroup: "android",
buildPath: "build/android/src/main/res/values/",
files: [
{
destination: "tokens_colors.xml",
format: "android/colors",
filter: { attributes: { category: "color" } }
}
]
}
}
};
The outputReferences: true option on the CSS platform preserves alias chains — for example, var(--ds-color-surface-default) points at var(--ds-color-neutral-0) instead of resolving to a flat value. This enables runtime theming.
Compiled platforms like iOS and Android have no runtime cascade, so they resolve aliases at build time.
Platform-Specific Transform Concerns
Web (CSS Custom Properties)
- Use
outputReferences: trueso semantic tokens compose at runtime in the browser. Dark mode can then redefine primitive values, and all semantic tokens pick them up automatically. - Scope tokens to
:rootby default. Scope theme overrides to a[data-theme="dark"]attribute selector. - OKLCH is natively supported in all modern browsers. For legacy targets, run a PostCSS plugin to generate
@supportsfallbacks withrgba()equivalents.
iOS (Swift / SwiftUI)
- Style Dictionary’s
ios-swifttransform group outputs dimension tokens asCGFloatvalues. Verify that your design token scale uses points-equivalent logical units to avoid off-by-one scaling errors. - Color tokens can output as
Color(SwiftUI) orUIColor(UIKit). For dark mode, generate an.xcassetscolor set with light and dark appearance variants, or produce a Swift extension that reads fromUITraitCollection. - Because Swift has no runtime cascade, aliases resolve to their final values at build time. Semantic tokens lose the “live pointer” benefit that CSS custom properties provide.
Android (XML / Compose)
- For legacy View-based Android: output
colors.xmlanddimens.xmlusing Style Dictionary’s built-inandroidtransform group. - For Jetpack Compose: output a Kotlin file with a
MaterialThemeextension. Style Dictionary has community-maintained Compose formatters. Terrazzo ships first-class Compose output as an alternative. - Android uses ARGB hex ordering (
0xFFRRGGBB), not RGBA. The transform group handles this automatically, but verify the output when OKLCH requires an intermediate sRGB resolve step.
Theming Across Platforms
Dark mode and brand theming require different implementation strategies per platform. Your token structure should make them composable everywhere.
The Three-File Pattern
A practical structure for multi-theme, multi-platform output:
| File | Contents | Used by |
|---|---|---|
primitives.json | Raw color palette (neutral-0 through neutral-1000), spacing scale, radii | Build tool only |
semantic.light.json | Surface, text, border tokens referencing primitives — light values | All platforms, light theme |
semantic.dark.json | Same token names, different primitive references — dark values | All platforms, dark theme |
This pattern keeps token names identical across themes. On web, the dark file generates a [data-theme="dark"] block that overrides the same custom property names. On iOS, it generates the dark-appearance entries in .xcassets. On Android, it populates a night/ resource qualifier folder.
Avoid Token Naming That Encodes Mode
Do not name semantic tokens light-surface-default or dark-text-primary. Token names should describe purpose, not appearance mode. The theme file is what determines which value applies in which context.
Do
color-surface-default, color-text-primary, color-border-subtle. Output two separate files — one per theme — that define the same names with different values. Each platform’s theming mechanism selects the right file.Don't
light-background, dark-text-inverse, blue-500. These names break the moment you add a third theme or rebrand, and they give platforms no clean way to swap values via their native theming systems.Automating Delivery: CI Pipeline Integration
A token pipeline that only runs when someone triggers it manually will drift. The right model is continuous delivery: every merge to the token repository triggers a build that pushes generated outputs to all consumers.
A typical CI pipeline:
# .github/workflows/build-tokens.yml
name: Build and distribute tokens
on:
push:
branches: [main]
paths: ["tokens/**"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build:tokens
# Outputs land in build/web/, build/ios/, build/android/
- name: Publish web tokens to npm
run: npm publish --workspace packages/tokens-web
- name: Open PR in iOS repo with updated Swift file
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.CROSS_REPO_PAT }}
path: build/ios/
branch: tokens/auto-update
- name: Open PR in Android repo with updated XML
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.CROSS_REPO_PAT }}
path: build/android/
branch: tokens/auto-update
Three key decisions in this setup:
- Web tokens publish to npm — platform teams install the package and get updates through their normal dependency workflow.
- Native platform outputs open PRs — instead of force-pushing to native repos, the pipeline opens a pull request. This gives native engineers visibility and a merge gate, rather than silent file mutations.
- The
pathsfilter prevents CI from running on every commit — only token file changes trigger a build.
Tooling Comparison
| Tool | DTCG Support | Platforms | Notes |
|---|---|---|---|
| Style Dictionary v4 | Native | Web, iOS, Android, Flutter, custom | Most extensible; large ecosystem; requires config work |
| Terrazzo | Native | Web, iOS, Android, Compose | Opinionated; faster setup; first-class Compose |
| Token Pipeline (Figma Tokens plugin) | Partial | Web-focused | Good for Figma-first workflows; less platform depth |
| Theo (deprecated) | No | Web only | Avoid for new projects |
Style Dictionary is the most flexible choice for teams with non-standard output needs. Terrazzo trades configurability for a faster setup and is a strong choice for greenfield multi-platform projects.
Common Failure Modes
Teams implementing multi-platform token output tend to hit the same problems:
- Alias resolution depth — aliases that reference other aliases (three or more hops) can cause circular reference errors in some tooling. Keep alias chains shallow: primitives referenced by semantics, semantics referenced by components. No component tokens referencing other component tokens.
- Dimension unit mismatch — a token stored as
16(unitless dimension) is correct. A token stored as"16px"will produce"16pxpt"when the iOS transform appendspt. Audit your dimension tokens before adding native platform outputs. - Name collisions in generated code — Swift and Kotlin have reserved words and naming constraints. A token named
defaultconflicts with a Swift reserved word. Add a prefix (ds-ortoken-) at the config level to avoid this. - Multiple source-of-truth syndrome — if the Figma file is the “real” token source and the JSON file is a manual export, they will drift. Use the Figma Tokens Plugin or Token Studio with a GitHub sync to make the JSON the canonical source that Figma reads from, not the other way around.