Let's build a multi-platform Design System, step by step
Web, iOS, Android. One color definition. Changed once, propagated everywhere.
Instead of a theory article, we're going to build a working POC, step by step. By the end, you'll have a mini design system that generates CSS, Swift, and Kotlin from a single source, with two brands, light/dark themes, a demo, and regression tests. All the code is on GitHub — clone it and follow along.
The guiding thread: a design decision should exist in only one place, in a neutral form; everything else is derived from it automatically.
The problem we're solving
Your brand's primary color — a blue — lives in parallel in CSS variables (web), Swift (iOS), Kotlin (Android)… The problem isn't creating those values, it's keeping them in sync over time. One day someone changes the blue on web but not on Android, and the product becomes inconsistent. Multiply by 2 brands and 2 themes: the inconsistency becomes structural.
The root cause: the same decision is copied by hand into different formats. We're going to kill the copy.
Step 0 — The project
mkdir design-system-poc && cd design-system-poc
npm init -y
npm install -D style-dictionary typescript @types/node
Style Dictionary is the reference tool for turning "tokens → multiple targets". Add "type": "module" to your package.json (we write ESM) and a tsconfig.json in strict mode. We code in TypeScript: Node 24 runs it natively (type stripping), so node build.ts works with no compile step — tsc --noEmit is only there for type-checking.
Step 1 — Primitives (the ingredients)
A design token is a name → value pair describing a design intent, independent of any language. We start with the raw palette, with no business meaning.
tokens/primitives.json:
{
"color": {
"blue": { "300": { "value": "#60A5FA", "type": "color" },
"500": { "value": "#2563EB", "type": "color" },
"700": { "value": "#1D4ED8", "type": "color" } },
"green": { "500": { "value": "#059669", "type": "color" },
"700": { "value": "#047857", "type": "color" } },
"grey": { "0": { "value": "#FFFFFF", "type": "color" },
"100": { "value": "#F3F4F6", "type": "color" },
"900": { "value": "#111827", "type": "color" } }
},
"space": { "2": { "value": "8px", "type": "spacing" },
"4": { "value": "16px", "type": "spacing" } },
"radius": { "sm": { "value": "4px", "type": "borderRadius" },
"md": { "value": "8px", "type": "borderRadius" } }
}
These values "know" nothing about web or mobile. They're the neutral form everything else will derive from.
Step 2 — Semantic tokens (the secret to multi-brand)
The beginner trap: putting #2563EB directly in components. Instead, we create a semantic level that describes usage and points to a primitive (the {color.blue.500} syntax is a Style Dictionary reference).
tokens/semantic.brandA.light.json:
{
"color": {
"primary": { "value": "{color.blue.500}", "type": "color" },
"primary-hover": { "value": "{color.blue.700}", "type": "color" },
"on-primary": { "value": "{color.grey.0}", "type": "color" },
"surface": { "value": "{color.grey.0}", "type": "color" },
"on-surface": { "value": "{color.grey.900}", "type": "color" }
}
}
The dark theme keeps the same semantic names, but rewires to different primitives — tokens/semantic.brandA.dark.json:
{
"color": {
"primary": { "value": "{color.blue.300}", "type": "color" },
"surface": { "value": "{color.grey.900}", "type": "color" },
"on-surface": { "value": "{color.grey.100}", "type": "color" }
}
}
And a 2nd brand = just different wiring — tokens/semantic.brandB.light.json:
{
"color": {
"primary": { "value": "{color.green.500}", "type": "color" },
"primary-hover": { "value": "{color.green.700}", "type": "color" },
"surface": { "value": "{color.grey.0}", "type": "color" }
}
}
Why this matters: your components reference only color-primary, never blue-500. Switching brand or theme = rewiring the semantic links, without touching a single line of any component.
Step 3 — Generate for each platform
We give Style Dictionary the tokens + the target formats, and it generates the rest. The key point: ship idiomatic code per platform, not raw JSON. An iOS dev wants typed Swift.
We have 3 combinations (brand × theme) to generate, so we loop. build.ts:
import StyleDictionary from 'style-dictionary';
import type { Config } from 'style-dictionary/types';
const combinations = [
{ brand: 'brandA', theme: 'light' },
{ brand: 'brandA', theme: 'dark' },
{ brand: 'brandB', theme: 'light' },
];
const platformsFor = (name: string): Config['platforms'] => ({
css: { transformGroup: 'css', buildPath: 'build/web/',
files: [{ destination: `${name}.css`, format: 'css/variables' }] },
ios: { transformGroup: 'ios-swift', buildPath: 'build/ios/',
files: [{ destination: `${name}.swift`, format: 'ios-swift/class.swift',
options: { className: 'Tokens' } }] },
android: { transformGroup: 'compose', buildPath: 'build/android/',
files: [{ destination: `${name}.kt`, format: 'compose/object',
options: { className: 'Tokens', packageName: 'com.monorg.tokens' } }] },
json: { transformGroup: 'js', buildPath: 'build/json/',
files: [{ destination: `${name}.json`, format: 'json/flat' }] },
});
for (const { brand, theme } of combinations) {
const name = `${brand}-${theme}`;
const sd = new StyleDictionary({
// Source = the common layer (primitives) + this combination's semantic links.
source: ['tokens/primitives.json', `tokens/semantic.${brand}.${theme}.json`],
platforms: platformsFor(name),
});
await sd.buildAllPlatforms();
}
node build.ts
And there's the same decision, translated into each language. Web (build/web/brandA-light.css):
:root {
--color-primary: #2563eb;
--color-on-primary: #ffffff;
--color-surface: #ffffff;
--color-on-surface: #111827;
--space-4: 16px;
--radius-md: 8px;
}
iOS (build/ios/brandA-light.swift):
public class Tokens {
public static let colorPrimary = UIColor(red: 0.145, green: 0.388, blue: 0.922, alpha: 1)
// ...
}
Android / Compose (build/android/brandB-light.kt):
package com.monorg.tokens
object Tokens {
val colorPrimary = Color(0xff059669) // brandB → green
val colorPrimaryHover = Color(0xff047857)
}
One source, three languages, idiomatic.
Step 4 — A component that consumes (and the magic of the switch)
The component uses only semantic tokens, no hard-coded colors. demo/index.html:
<link id="theme" rel="stylesheet" href="../build/web/brandA-light.css" />
<style>
.btn {
background: var(--color-primary);
color: var(--color-on-primary);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
}
.btn:hover { background: var(--color-primary-hover); }
</style>
<button class="btn">Primary action</button>
<script>
// The only switch: we swap the token stylesheet. The component does not change.
document.getElementById('theme').href = '../build/web/brandA-dark.css';
</script>
Change brandA-light → brandA-dark → brandB-light: the button re-themes itself. Not a single line of the component moves. That's the whole point of the semantic level.
Step 5 — Test regressions (the indispensable safety net)
In a system consumed everywhere, a silent regression spreads everywhere at once. We add a net, with zero dependencies, using node:test.
A deliberate choice: writing these tests in Gherkin (Feature / Scenario / Given / When / Then). Why? Because a design system is consumed as much by non-developers (designers, PMs) as by devs. Phrased this way, the tests become documentation that reads like sentences: "Given brand A in dark, when generated, then primary points to blue 300". No need to read code to know what the system guarantees. test/tokens.test.ts:
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const read = (combo: string): Record<string, string> =>
JSON.parse(readFileSync(new URL(`../build/json/${combo}.json`, import.meta.url), 'utf8'));
describe('Feature: semantic links resolve to the right primitives', () => {
describe('Scenario: the dark theme reuses the same semantic names', () => {
it('Given brand A in dark, When generated, Then primary points to blue 300', () => {
const tokens = read('brandA-dark');
assert.equal(tokens.ColorPrimary, '#60a5fa');
assert.equal(tokens.ColorSurface, '#111827');
});
});
describe('Scenario: switching brand is just re-wiring the links', () => {
it('Given brand B in light, When generated, Then primary = green', () => {
assert.equal(read('brandB-light').ColorPrimary, '#059669');
});
});
});
node build.ts && node --test "test/**/*.test.ts"
# ✔ Feature: semantic links resolve to the right primitives
# ✔ Scenario: the dark theme reuses the same semantic names
# ✔ Scenario: switching brand is just re-wiring the links
If someone breaks a primitive or a link, the test fails before the regression reaches the products. The repo wires this into a GitHub Actions CI that, on every push, builds all combinations, checks the multi-platform artifacts were actually generated, and runs the tests — so a broken link never reaches main. In production, you complement this with snapshot tests on the generated files (any change to an output becomes explicit in review) and, on the component side, a Storybook + automated visual review (Chromatic-style).
Step 6 — Assets (the real challenge: icons & fonts)
Colors are the easy part. Where a design system earns its stripes is on assets: an icon or a font must also start from a single source and be translated into each platform's format.
The most telling case: Android doesn't read SVG natively, it needs a Vector Drawable XML. So we write a small SVG → Vector Drawable converter (excerpt):
export function svgToVectorDrawable(svg: string, { size = 24 } = {}): string {
const [, , w, h] = svg.match(/viewBox="([\d.\s-]+)"/)![1].trim().split(/\s+/).map(Number);
const paths = [...svg.matchAll(/<path\b[^>]*\bd="([^"]+)"[^>]*>/gi)].map((m) => {
const fill = (m[0].match(/fill="([^"]+)"/) || [])[1] ?? 'currentColor';
const color = fill === 'currentColor' ? '#FF000000' : `#FF${fill.slice(1).toUpperCase()}`;
return ` <path android:fillColor="${color}" android:pathData="${m[1]}" />`;
});
return `<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="${size}dp" android:height="${size}dp"
android:viewportWidth="${w}" android:viewportHeight="${h}">
${paths.join('\n')}
</vector>`;
}
From a single assets/icons/check.svg, the pipeline produces:
- Web → the SVG as-is + a JSON manifest;
-
iOS → a type-safe
enum Icon(no more "magic strings") + the SVG; -
Android →
ic_check.xml(Vector Drawable) + anenum Icon.
Same logic for the font: one Inter.ttf → @font-face (web), FontRegistrar.swift (iOS), res/font/ (Android). The full code is in the repo (src/buildAssets.ts).
That's the difference between "a shared color file" and a real design system: the multi-platform asset chain.
Step 7 — Distribute it like a real product
A design system only has value if it's easy to consume. Never ask people to copy files: publish to native package managers, and version with SemVer (with alpha / beta / rc / stable channels).
npm install @monorg/design-tokens@1.2.0 # web
implementation("monorg:design-tokens:1.2.0") // android (Maven)
Automate it via CI: a version tag triggers a pipeline that builds all formats and publishes each package to its registry (npm / SPM / Maven). No manual step = no drift between the repo and what's shipped.
# pseudo-pipeline
on: tag 'tokens-v*'
jobs:
build: node build.ts
publish: [ npm, spm, maven ] # one job per registry
The real win: each consumer pins ITS version
This is the point people underestimate at first. Once tokens are published as versioned packages, each application picks the version it consumes — and that's what makes the system usable at scale:
-
Total decoupling. The design team can publish
1.3.0today without breaking anything: the iOS app stays on1.2.0until it decides to bump. Nobody is forced to move at the same time. -
Progressive rollout. You test a new palette on one product (which goes to
1.3.0-alpha.1) while the others stay stable. If it breaks, the blast radius is one repo, not all of them. -
Reproducible builds. A lockfile pinned to
1.2.0produces exactly the same rendering in six months. A "shared folder" that changes under your feet does not. -
History and rollback. A visual regression in prod? Revert to the previous version in one line (
@1.1.0) while you fix it. No panicked hotfix across 6 codebases.
design-tokens v1.3.0 ← latest published
├── web app "@monorg/design-tokens": "1.3.0" (up to date)
├── iOS app from: "1.2.0" (bumps when it wants)
└── Android app "monorg:design-tokens:1.2.0" (same)
The design system becomes a dependency like any other: you update it when you're ready, you read the CHANGELOG, you pin, you roll back. It's exactly this versioning mechanism that turns a "shared folder" into infrastructure.
Wrapping up
A design system is not a button library. It's infrastructure, and its value is in the chain:
- A single source of truth: the tokens, in neutral form.
- Three levels: primitives → semantic → component. That's what makes multi-brand manageable.
- A generation to idiomatic code per platform (Style Dictionary).
- A distribution versioned with SemVer, via native package managers.
- Tests (Gherkin + snapshots + visual review) to change without fear — and that document the system, even for non-devs.
The full POC code is here → github.com/ibrahimdans/design-system-poc. Clone it, npm install, npm run demo.
The real question isn't "which components should go in my design system?". It's:
"Do my design decisions have a single source of truth, or are they copied by hand?"
Start there.
Unifying several products or several platforms? I design design systems that scale. Let's talk on LinkedIn.
Top comments (0)