One System. Four Surfaces. No Drift.
Every studio eventually inherits this repo: a Figma file, a sheet of CSS custom properties, and an iOS engineer transcribing hex values into an asset catalog. It holds for one release — then someone changes the accent, the web ships it that afternoon, and the app glows the old cyan for six weeks. That is a modelling problem, not a tooling gap: the tokens were built for one surface and then sent to the others.
Why Web-Only Tokens Rot
The pipeline is born the same way: Figma variables exported to JSON, flattened into :root, with the CSS file becoming the source of truth by accident.
It forks the moment iOS ships, because CSS values are browser values. rgba(255,255,255,0.03) is a compositor instruction; blur(24px) assumes a free GPU layer; 0.5px assumes subpixel borders. Hand that list to a UIKit or Compose engineer and they re-derive everything native, in points and dp. Two weeks later there are two token sets, one nobody rebuilds.
Naming rots faster than values. --glass-2 means “the second glass layer on the marketing site” to a web developer and nothing to anyone else. With no semantic layer every consumer reaches for the primitive — <Card> hardcodes #22D3EE “just for now”, and a rebrand becomes a hunt across three repos.
A token that describes the browser is a specification for the browser. Everything downstream is a translation, and unmanaged translations diverge.
Unique colour literals predict design debt better than component count. We count them first — it is how a recent rebuild started, before a pixel moved.
The Three Tiers
Primitive, semantic, component. Dependency flows one way and never backwards.
- Primitive — raw material:
#050608,#22D3EE,260ms. No meaning; nothing outside the token file may reference these. - Semantic — intent:
color.accent.interactive,surface.canvas,border.hairline,motion.duration.base. The vocabulary engineers write against. - Component — a control’s contract:
button.primary.bg,card.glass.stroke. Components consume semantics, and only semantics.
The rule that carries the system: a component may never reference a primitive. The moment <Button> pulls frost-cyan directly, re-theming means editing components — and you have a folder of hex values with opinions, not a system.
{
"color": {
"primitive": {
"canvas": { "$value": "#050608", "$type": "color" },
"frost-cyan": { "$value": "#22D3EE", "$type": "color" },
"sapphire": { "$value": "#1E40AF", "$type": "color" }
},
"semantic": {
"accent": {
"interactive": { "$value": "{color.primitive.frost-cyan}", "$type": "color" },
"focus-ring": { "$value": "{color.primitive.frost-cyan}", "$type": "color" }
},
"surface": {
"canvas": { "$value": "{color.primitive.canvas}", "$type": "color" },
"glass": { "$value": "rgba(255, 255, 255, 0.03)", "$type": "color" }
},
"ambient": {
"pool": { "$value": "{color.primitive.sapphire}", "$type": "color" }
}
}
}
}The same source compiles to three consumers with no human touching a value:
/* web — assets/css/tokens.css */
:root {
--color-accent-interactive: #22D3EE;
--color-surface-canvas: #050608;
--color-surface-glass: rgba(255, 255, 255, 0.03);
}
// iOS — Tokens.swift
public enum ColorTokens {
public static let accentInteractive = UIColor(hex: 0x22D3EE)
public static let surfaceCanvas = UIColor(hex: 0x050608)
}
// Android — Tokens.kt
object ColorTokens {
val AccentInteractive = Color(0xFF22D3EE)
}Notice what the semantic tier buys: color.accent.interactive and color.accent.focus-ring both resolve to Electric Frost Cyan #22D3EE today — a cyan focus ring on a cyan button is a contrast trap. Split them next quarter: two JSON entries, zero components.
Naming That Survives Translation
Write it once in dot segments; the case convention is all a platform changes:
- source:
color.accent.interactive - CSS:
--color-accent-interactive - Swift:
ColorTokens.AccentInteractive - Kotlin:
ColorTokens.AccentInteractive
Segments and order stay constant, color is always first, and an engineer who has never seen the Kotlin output can still guess it.
Dark mode is a value swap, not a fork. surface.canvas resolves to #050608 in dark and its light counterpart in light; the component asks the same name in both. Ask for surface.canvas.dark and you own two systems plus a branch to QA forever. Theme is a resolve-time mapping: data-theme in CSS, a trait collection in Swift, a colorScheme in Compose.
Our headline role is Space Grotesk at -0.04em tracking — a web measurement. Baking it into the iOS build as fixed 44pt ships an accessibility bug with excellent taste. The token names the role; each platform owns the ramp: web clamp(), iOS a Dynamic Type style tracking the content size category, Compose sp. Tracking ships separately with a per-platform unit.
Before a token merges, break it: does the name contain a platform, a theme mode, or a pixel value? Then it is a value wearing a token’s badge.
Density and Motion, Per Platform
Some things genuinely do not translate. A 0.5px stroke in rgba(255,255,255,0.12) is how the browser draws a hairline — it lands on a half-pixel and the compositor keeps it crisp. UIKit has no 0.5pt line: you set layer.borderWidth = 1.0 / UIScreen.scale, one physical pixel at 2×; Compose draws 0.5.dp and lets density round it. The token is therefore border.hairline — an intent each platform renders its own way.
Glass is the same: surface.glass over the canvas with backdrop-filter: blur(24px). UIKit has no value you can set to 24 — you adopt UIVisualEffectView; Compose uses RenderEffect with a flat-scrim fallback. The token carries intent plus a fallback ladder, never a number that lies.
Motion tokens are time, never frames. fast = 160ms, base = 260ms, slow = 420ms, easing.standard = cubic-bezier(0.2, 0, 0, 1). Sixteen frames is 133ms at 120Hz ProMotion and 266ms at 60Hz — tokenise frames and the animation runs twice as fast on the nicer phone. Milliseconds go in the token; each platform’s display link decides the frame cost. The curve maps too: cubic-bezier(0.2,0,0,1) becomes UICubicTimingParameters on iOS, near Compose’s FastOutSlowInEasing.
prefers-reduced-motion is not a duration of zero: at 0ms entrances vanish with the movement. So the reduced path is per token — transforms become opacity-only crossfades at motion.duration.reduced = 120ms, while meaningful state changes keep their timing. The media query, UIAccessibility.isReduceMotionEnabled and Compose’s duration scale all read that one token.
The Pipeline
One source, three outputs. We use Style Dictionary; the same shape holds if you write your own generator.
{
"source": ["tokens/**/*.json"],
"platforms": {
"css": { "transformGroup": "css",
"buildPath": "assets/css/",
"files": [{ "destination": "tokens.css",
"format": "css/variables" }] },
"swift": { "transformGroup": "ios-swift",
"buildPath": "Sources/LevarluxTokens/",
"files": [{ "destination": "Tokens.swift",
"format": "ios-swift/classic" }] },
"compose": { "transformGroup": "android-compose",
"buildPath": "core/design/src/main/kotlin/",
"files": [{ "destination": "Tokens.kt",
"format": "android-compose/object" }] }
}
}Unit handling lives in custom transforms, so browser units never leak into native output:
// build/tokens.transforms.js
// 16px of CSS radius is 12pt of UIKit; motion tokens stay untouched.
styleDictionary.registerTransform({
name: "size/px-to-pt",
type: "value",
filter: (token) => token.$type === "dimension" && token.path[0] !== "motion",
transformer: (token) => round(token.$value * 0.75)
});
// $ npm run tokens -- --check && npm run build
// no token whose name starts with "comp." may reach a file outside componentsVersioning is not optional. Every merge that adds or retires a token bumps the design-tokens package, a dependency of all four apps — an upgrade PR is the release mechanism. Deprecation is two steps: mark the token with a replacement, keep generating it for two releases while the compiler nags (@available(*, deprecated, renamed:), @Deprecated, a flagged CSS comment), then drop it and let CI fail on stragglers.
Governance
Tools enforce what culture cannot. Four boring rules:
- Who may add a token. The design-systems owner plus one platform engineer, both approving. Everyone else opens a discussion, not a pull request.
- The review bar. The PR answers three questions: which semantic gap does it fill, which component consumes it on day one, what happens where it cannot be expressed? A token with no consumer is speculation.
- No raw hex in components. A stylelint
declaration-property-value-disallowed-listmatching/#[0-9a-f]{3,8}/i,rgb(banned outside token files,no-hex-colorsin TS, plus a SwiftLint and a detekt rule. Token files are the only allowlisted paths. - Drift scan in CI. A forty-line script walks the component trees and fails on hardcoded colour, radius, border width or duration.
Screenshot diffing still matters, but we diff by token, not by pixel: compare the computed values of token-consuming selectors and reserve pixel diffs for layout — subpixel antialiasing and GPU blur variance make them flaky on a dark, blurred interface. It is why our engineering audits start with counts.
When Platforms Legitimately Disagree
Some disagreements are correct; the job is making them explicit so nobody re-litigates them quarterly.
| Property | Browser | iOS | Why they differ |
|---|---|---|---|
| Hairline stroke | 0.5px, rgba(255,255,255,.12) | 1.0 / UIScreen.scale | Pixel grids differ; a hairline must land on whole device pixels. |
| Type scale | clamp(32px, 5vw, 56px) | Dynamic Type text style | Web sizes for the viewport; iOS must honour the content size category. |
| Tracking | -0.04em | -0.8pt | Core Text tracks absolutely; em would drift with the size category. |
| Corner radius | 28px arc, pill 9999px | .continuous curve | CSS radius is a circular arc; UIKit’s squircle has different tangents. |
| Blur | backdrop-filter: blur(24px) | UIVisualEffectView material | Apple’s materials bundle vibrancy and a render budget. |
| Tap target min | 44px under (pointer: coarse) | 44 × 44pt, always | Web gets hover and precise pointers; touch never does. |
Every row ships in the token package with its reasoning, so “why is iOS not 0.5px” is a link, not a meeting. Documented divergence is a decision; undocumented divergence is drift.
The Audit Checklist
Run this against your own system this week; the first three steps are an afternoon.
- Count the literals: hex,
rgb(, bare radii, baremsdurations in components. That number is your design debt. - Extract primitives from what you counted — do not invent a new palette while the old one ships.
- Write the semantic layer in the words your designers already say: glass, hairline, accent, canvas.
- Repoint every component at semantics; delete primitive access. One pull request per surface.
- Generate outputs for every surface you ship, even if v1 is Swift constants plus a Kotlin object.
- Wire deprecation and version the token package before the second team consumes it.
- Land lint rules on day one: no hex outside token files, no raw durations, no platform-qualified names.
- Turn on the CI token-usage scan and the computed-style diff — by token, not by pixel.
- Write your own disagreement table and pin it where reviewers will see it.
None of this is exotic: a week of work plus the discipline to say no to the next hardcoded cyan. If your system is past that — four surfaces, three teams, a decade of components — the untangling is our work; start there.
Reading About It Is the Cheap Part.
If your token set stops at the browser and you can feel the fork coming, bring it to a principal engineer — thirty minutes is usually enough to tell whether it is a modelling problem or a migration problem.