Design tokens and theming: the contract between design and code
Tokens are a three-tier contract: primitive palette, semantic intent, component bindings. Themes re-declare the semantic tier — data-theme flips custom properties, zero re-render. Ship tokens as a versioned package, alias renames, lint raw hex; a rebrand takes days, not months.
The acquisition closed on a Tuesday; the mandate arrived Thursday: new brand across every product surface within one quarter. Four web apps, one of them on a disciplined three-tier token system, three of them styled the way most apps are — hex codes where hex codes fell. The token-based app finished in six working days: the palette tier got the new brand colors, the semantic mappings were reviewed against contrast requirements, dark mode came along automatically because it lived in the semantic tier, and product code did not change at all — not one component file touched. The other three apps spent the rest of the quarter and then some: a find-replace across thousands of files that could not distinguish the brand blue from an identical blue in a chart, fourteen hand-tuned approximations of the old accent color that matched no search, a dark mode that broke twice because it had been implemented as inverted grays, and a long tail of screenshots from QA with mixed old-and-new blues on the same screen. Same company, same rebrand, same quarter: six days versus four months. The difference was not tooling talent — it was whether color existed as a contract or as ten thousand coincidences.
Three tiers, or no theming
A design token is a named design decision — --color-accent, not #2563eb. The naming is what makes it a contract: code references the decision, and the decision can change without the code knowing. Mature systems converge on three tiers with strictly one-directional references:
- Primitive palette — raw values with no meaning attached:
--blue-600: #2563eb,--gray-100,--space-4. A complete, ordered set of options. Nothing in product code may reference these. - Semantic tokens — intent:
--color-surface,--color-on-surface,--color-accent,--color-danger,--radius-interactive. Each maps to a primitive. This tier answers “what is this color for” — and it is where every theme lives. - Component tokens — per-component bindings consumed by the component’s styles:
--button-primary-bg: var(--color-accent). Optional but valuable at scale: they give each component a stable styling API and a place for targeted overrides.
Why the middle tier is the one you cannot skip: themes are re-mappings of intent. Dark mode does not say “make blue darker” — it says “surface is now near-black, on-surface is near-white, accent shifts one step lighter for contrast.” If component tokens bind directly to primitives — --button-bg: var(--blue-600) — there is no place to express that statement except by re-declaring every component token, hundreds of them, per theme. With a semantic tier, a theme is ~30 lines. And if product code uses primitives directly, the brand is hardcoded with extra steps: the rebrand from the Hook becomes find-replace again, just with prettier names.
A token system has a primitive palette and per-component tokens, no semantic tier. Dark mode gets estimated in weeks across the app rather than as one theme file. Why?
The runtime: CSS custom properties versus JS theme objects
Two implementation camps. CSS custom properties make the browser’s cascade the theming runtime:
:root {
--blue-600: #2563eb; /* primitive — never referenced by products */
--color-accent: var(--blue-600); /* semantic — the theme lives here */
--color-surface: #ffffff;
--color-on-surface: #111827;
}
[data-theme="dark"] {
--color-surface: #0b0f1a; /* re-map intent; primitives untouched */
--color-on-surface: #e5e7eb;
--color-accent: var(--blue-400); /* one step lighter for contrast on dark */
}Switching themes is document.documentElement.dataset.theme = "dark" — the browser recomputes styles and no React re-render happens at all, because no React state changed. It survives SSR (an inline script can set the attribute before first paint, killing the theme flash), works across micro-frontends and non-React surfaces, and costs nothing per component. The weaknesses are real: values are untyped strings (mitigated by generating TypeScript declarations from the token source), and anything computed from a token in JS needs getComputedStyle.
JS theme objects — a ThemeProvider holding { colors: { accent: ... } } consumed via context — give you typed, composable values in the language. The bill: the theme is runtime-coupled — switching themes changes the context value, which must re-render every consumer; at design-system scale that is thousands of components re-rendering to change colors the browser could have swapped in the cascade. It is also framework-locked. The synthesis that won in practice: author tokens as data, type them at build time, deliver them as CSS custom properties — typed authoring, cascade runtime.
One dark-mode rule transcends the implementation choice: theme at the semantic tier, never by inverting primitives. Inverted gray scales produce illegible disabled states and broken shadows; dark surfaces need elevation-based lightening (a raised card is lighter than the page, not darker, because shadows are nearly invisible on dark); accent colors often need a lighter step to keep contrast. These are design decisions per intent token — which is the entire argument for the tier they live in.
Theme toggling is implemented as a context-provided JS theme object consumed by ~1,800 styled components; toggling visibly drops frames. What is the near-zero-cost alternative?
Distribution: tokens as a versioned package
At more than one consuming app, tokens become an API with consumers, and need the discipline of one. The source of truth is a data file (JSON), built into platform outputs — CSS custom properties, a TypeScript module with types, mobile formats — and published as a versioned package consumed like any dependency. That enables the policies that keep the contract trustworthy:
- Breaking-change policy. Renaming a token is an API break. Ship deprecation aliases — the old name re-exported as a reference to the new one, with a build-time warning — for at least one major version; remove only on a major bump, ideally with a codemod.
- Drift control. The contract erodes one raw hex at a time. Lint for it: a stylelint rule forbidding raw color literals and px values in product code, enforced in CI. The token-based app in the Hook finished in six days because this lint had kept its codebase clean; the find-replace apps were paying down years of un-linted drift.
- Review the token diff like an API diff. A changed semantic mapping alters every consumer of that intent; the diff is small to read and huge in blast radius —
--color-accentchanging is a product-wide visual change reviewed in one file.
The interop layer, honestly
Tokens outlive any one toolchain, so the format matters. Style Dictionary is the established transformer: one token source in, per-platform outputs out (CSS, TS, iOS, Android), with custom transforms. The W3C Design Tokens Community Group format (the design-tokens/community-group spec on GitHub) is the interop bet: a JSON format with $value/$type/$description, designed so design tools, transformers, and documentation generators can exchange tokens. Honest status: it spent years as a draft, tool support is real but uneven — Style Dictionary v4 reads it, major design tools track it with varying fidelity — and migrating an existing token source to it is work. It is still the right bet, because the alternative is per-tool lock-in of the one artifact your design and code teams must share for years.
▸Why this works
Why do component tokens earn their place as a third tier instead of components consuming semantic tokens directly? Two reasons that only appear at scale. First, a stable styling API per component: when Button styles read --button-primary-bg, a product can re-skin one button family — a marketing CTA, a dense data-tool variant — by re-declaring three component tokens in a scoped selector, without touching the semantic tier that the rest of the page still needs. Second, indirection absorbs design churn: when design decides primary buttons should use a new emphasis color, the change is one component-token mapping; consumers of --color-accent elsewhere (links, focus rings) are untouched. The cost is real — three tiers of indirection to trace when debugging a color — which is why small systems legitimately run two tiers and add the third when the override and churn pressure arrives.
- 01Lay out the three token tiers, the reference direction, and the precise reason skipping the semantic tier kills theming.
- 02Compare CSS custom properties and JS theme objects as theming runtimes, and give the distribution discipline for multi-app token packages.
A design token is a named design decision, and the naming is the contract: code references intent, intent maps to values, and values can change without code knowing. The architecture that makes this real is three tiers with references pointing strictly downward — a primitive palette of raw meaningless values nothing in product code may touch, a semantic tier of intent tokens where every theme lives, and component tokens binding intent into each component’s stable styling API. The semantic tier is the one you cannot skip: a theme is a re-mapping of intent, expressible as a few dozen re-declarations — without the middle tier it decays into re-declaring hundreds of component bindings, and dark mode built by inverting grays breaks on shadows, elevation, and contrast, because dark themes are made of intent-level decisions like surfaces that lighten as they rise. At runtime, CSS custom properties beat JS theme objects for the switch itself: flipping data-theme re-styles through the cascade with zero React re-renders and no hydration dependency, while a context-held theme object must re-render every consumer to deliver new values — type the tokens at build time and let the cascade do the runtime. Beyond one app, tokens are an API with consumers: one JSON source built into per-platform outputs, versioned; renames shipped as deprecation aliases and removed only on majors; drift killed by CI lint against raw hex — the discipline that turned a corporate rebrand into six days for the tokenized app while its three siblings burned a quarter on find-replace. For interop, Style Dictionary plus the W3C community-group format is the honest bet: imperfect and draft-stage, but better than locking your longest-lived design artifact into one tool. Now when you face a rebrand, you can tell within minutes whether it will take days or months — open any product component file and count the raw hex codes.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.