[ Design Systems · Tokens ]

One System. Four Surfaces. No Drift.

color.accent.interactive  →  #22D3EE
surface.glass  →  rgba(255,255,255,0.03) · stroke 0.5px
surface.canvas  →  #050608 · pool #1E40AF
radius.surface  →  28–36px · pill 9999
effect.glass.blur  →  24px · material fallback
motion.duration.base  →  260ms · cubic-bezier(0.2,0,0,1)
surfaces  →  web · iOS · Android · desktop

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.

The fork test

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 components

Versioning 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-list matching /#[0-9a-f]{3,8}/i, rgb( banned outside token files, no-hex-colors in 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.

PropertyBrowseriOSWhy they differ
Hairline stroke0.5px, rgba(255,255,255,.12)1.0 / UIScreen.scalePixel grids differ; a hairline must land on whole device pixels.
Type scaleclamp(32px, 5vw, 56px)Dynamic Type text styleWeb sizes for the viewport; iOS must honour the content size category.
Tracking-0.04em-0.8ptCore Text tracks absolutely; em would drift with the size category.
Corner radius28px arc, pill 9999px.continuous curveCSS radius is a circular arc; UIKit’s squircle has different tangents.
Blurbackdrop-filter: blur(24px)UIVisualEffectView materialApple’s materials bundle vibrancy and a render budget.
Tap target min44px under (pointer: coarse)44 × 44pt, alwaysWeb 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.

  1. Count the literals: hex, rgb(, bare radii, bare ms durations in components. That number is your design debt.
  2. Extract primitives from what you counted — do not invent a new palette while the old one ships.
  3. Write the semantic layer in the words your designers already say: glass, hairline, accent, canvas.
  4. Repoint every component at semantics; delete primitive access. One pull request per surface.
  5. Generate outputs for every surface you ship, even if v1 is Swift constants plus a Kotlin object.
  6. Wire deprecation and version the token package before the second team consumes it.
  7. Land lint rules on day one: no hex outside token files, no raw durations, no platform-qualified names.
  8. Turn on the CI token-usage scan and the computed-style diff — by token, not by pixel.
  9. 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.

Put It to Work

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.