Frontend15 min readJun 2026 Last verified Jun 2026
Design Tokens and Theming: One Source of Truth for Every Design Decision
Design tokens turn scattered hex codes and magic numbers into named, layered design values. Learn the three-tier model, build dark mode without touching a single component, and bridge Figma to code.
Replace scattered hex codes with a three-tier token model: primitive, semantic, component. Build dark mode and multi-brand theming without touching a single component, and wire Figma to code so design and build stay in sync.
01The hardcoded-color problem
Who this is for
Frontend developers who keep copy-pasting the same #3b82f6 into component after component, and who flinch when a designer says "we're rebranding" or "we need a dark mode." If you've ever run a find-and-replace across a codebase to change one shade of blue, this article is for you.
Picture a real codebase. A button uses #3b82f6. So does a link. So does the focus ring, the active tab, the selected row, the progress bar. The brand color is hardcoded in 200 files. Then the brand evolves to a slightly different blue, and you discover there is no single place to change it, only 200 places, each one a chance to miss a spot or break something.
Design tokens fix this. A token is a named design value, a color, a spacing step, a font size, a border radius, a shadow, stored once and referenced everywhere. Change the token, and every component that points at it updates at once. The hex code lives in exactly one place, and the rest of the app speaks in *names*, not numbers.
02A style guide, not sticky notes
A design token is a named, reusable design decision, the single source of truth that both design and code agree on.
A brand style guide that says 'Primary Blue = the one true blue, use it for all calls to action'A semantic token --color-primary defined once and referenced by every interactive element
Sticky notes on every desk, each with a slightly different blue scrawled on it#3b82f6, #3a82f7, #3b81f5 hardcoded across 200 component files
Reprinting the style guide's color page when the brand changes, everyone reads the new valueEditing one token value and watching the whole app rebrand instantly
A 'dark mode' edition of the style guide with the same labels but different swatchesRe-pointing semantic tokens under [data-theme=dark], same names, new values
Tokens make the design decision a shared vocabulary instead of a number everyone copies.
03The three tiers of tokens
The magic of a token system is not having tokens, it is layering them. Mature systems use three tiers, where each tier references the one before it. This indirection is exactly what makes dark mode and rebrands cheap.
Tokens flow left to right: a raw palette value becomes a meaning, which a component consumes.
1
Primitive (base) tokens
The raw palette. --blue-500: #3b82f6. These are pure values with no meaning attached, just every shade you might ever reach for. Components must NOT use these directly.
2
Semantic (alias) tokens
Meaning, not value. --color-primary: var(--blue-500). This is the layer that names intent: primary, surface, text, danger, border. This is the layer themes re-point.
3
Component tokens
Optional, most specific. --button-bg: var(--color-primary). Scopes a semantic token to one component so you can tweak a button without touching the global primary.
The rule that makes it all work
Components reference semantic (or component) tokens only, never primitives. That single discipline is what lets you swap themes and rebrand without opening a single component file.
04Tokens as CSS custom properties
CSS custom properties (CSS variables) are the natural runtime home for tokens. They cascade, they inherit, they can be redefined per-scope, and they update live with no rebuild. Define primitives and semantics on :root.
Notice the chain: --button-bg points at --color-primary, which points at --blue-500. Three hops, but a real value at the end. Each tier is editable in isolation.
05A component that uses ONLY semantic tokens
Here is the payoff. The button never mentions a hex code or a primitive, it speaks entirely in semantic and component tokens. It has no idea what blue is, and it does not need to.
This component is now theme-agnostic. Light mode, dark mode, high-contrast, a second brand, none of them require a single edit here. All the variation lives in the token layer below it.
06Dark mode without touching components
Theming is just re-pointing semantic tokens in a new scope. Put a data-theme attribute on the <html> element and override the semantic layer underneath it. Primitives stay the same (the palette does not change); only the *meaning* mapping changes.
css
/* themes.css */
/* Dark: re-map semantics to darker palette entries. */
[data-theme="dark"] {
--color-surface: var(--gray-900);
--color-text: var(--gray-50);
--color-border: #374151;
--color-primary: #60a5fa; /* a lighter blue reads better on dark */
--color-primary-hover: #93c5fd;
}
/* High-contrast: maximize separation for low-vision users. */
[data-theme="high-contrast"] {
--color-surface: #000000;
--color-text: #ffffff;
--color-border: #ffffff;
--color-primary: #ffff00;
--button-fg: #000000;
}
Switching themes is one line of JavaScript, set the attribute and the cascade does the rest. No re-render of styled components, no class churn, no flash if you set it before paint.
typescript
// theme.ts, flip the whole app's appearancetype Theme = "light" | "dark" | "high-contrast";
exportfunctionsetTheme(theme: Theme): void {
if (theme === "light") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", theme);
}
localStorage.setItem("theme", theme);
}
// Restore on load, and respect the OS preference as the default.exportfunctioninitTheme(): void {
const saved = localStorage.getItem("theme") as Theme | null;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setTheme(saved ?? (prefersDark ? "dark" : "light"));
}
The button did not change
Re-read the dark and high-contrast blocks above: not one line of button.css or Button.tsx was touched. The component still says var(--button-bg); the value behind that name simply differs per theme. That is the entire point of the semantic tier.
07Multi-brand theming with the same mechanism
If you ship a white-label product or run several brands on one codebase, the exact same trick scales. Add a data-brand attribute and re-point the semantic primary, surface, and font tokens per brand. Components, and even your dark-mode overrides, keep working unchanged.
Brand and theme are orthogonal layers that compose. data-theme="dark" data-brand="acme" gives you Acme's dark palette without a combinatorial explosion of stylesheets, each axis is defined once.
08Primitive vs semantic vs component: who edits what
Tier
What it names
Who edits it
Primitive / base
Raw values: --blue-500, --space-4. No meaning, just the palette and scale.
Design-system maintainers, rarely. Adding a new shade or scale step.
Semantic / alias
Intent: --color-primary, --color-surface, --color-danger. Maps meaning to a primitive.
Theme authors. This layer is re-pointed per theme and per brand.
Component
Scope: --button-bg, --card-shadow. A semantic token narrowed to one component.
Component owners, for local tweaks without changing global intent.
Each tier has a different audience and a different rate of change, primitives barely move, semantics move per theme, component tokens move per component.
09The Figma to Style Dictionary to code pipeline
Tokens should not be hand-typed twice, once in Figma and once in CSS. The industry-standard bridge is Style Dictionary: designers define tokens in Figma (exported as the W3C-format tokens.json), and Style Dictionary transforms that single JSON into CSS variables, iOS, Android, JS, any platform you target.
Run style-dictionary build and the {color.blue.500} reference resolves into a real --color-primary in your generated tokens.css. Designers change a value in Figma, you re-export and rebuild, and web plus mobile update together. The JSON, not the CSS, is the true source of truth.
10Accessibility: contrast must survive every theme
A theme is only valid if it is readable. WCAG requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text and UI components. A dark theme that re-points --color-text to a dim gray on a dark surface can silently fail this, and tokens make it easy to introduce that bug if you do not check each theme.
Re-check contrast for every theme, not just light
The same semantic name (--color-text on --color-surface) maps to different value pairs per theme. Each pairing is a separate contrast obligation. Automate it: feed your token pairs to a contrast checker in CI so a dark or high-contrast theme can never ship below 4.5:1. See our deeper guide on web accessibility (a11y).
typescript
// contrast.ts, a CI guard over token pairsfunctionluminance(hex: string): number {
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const lin = c.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
exportfunctionratio(fg: string, bg: string): number {
const [a, b] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
return (a + 0.05) / (b + 0.05);
}
// Fail the build if any theme's text/surface pair dips below 4.5:1.if (ratio("#f9fafb", "#111827") < 4.5) {
thrownewError("Dark theme text fails WCAG AA");
}
11Common mistakes that cost hours
1Using primitive tokens directly in components. Writing color: var(--blue-500) in a button defeats the whole system, that component will not respond to dark mode or a rebrand. Always go through a semantic token.
2No naming convention.--blue, --brandColor, --primary-color-2 in the same codebase means nobody can guess a token name. Pick a pattern (--color-<intent>-<state>) and enforce it.
3Hardcoding values 'just this once.' That one margin-top: 13px becomes the value no token can override. If it is a design decision, it is a token.
4Skipping the semantic tier. Aliasing component tokens straight to primitives (--button-bg: var(--blue-500)) reintroduces the rebrand problem one layer up. Keep meaning in the middle.
5Theming only colors. Spacing, radius, type scale, and shadow are tokens too. High-contrast mode often needs thicker borders, not just new colors.
6Not testing contrast per theme. A theme that looks slick in a screenshot can be unreadable for low-vision users. Gate it in CI.
12Takeaways
The whole article in seven lines
A design token is a named design value, the single source of truth for one decision.
Three tiers: primitive (raw palette) → semantic (meaning) → component (scoped). Each references the one before.
Components reference semantic tokens only, never primitives, that discipline is the entire payoff.
Theme by re-pointing semantic tokens under [data-theme=...]; components never change.
Multi-brand uses the same trick with data-brand; theme and brand compose as orthogonal layers.
Style Dictionary turns one Figma-exported tokens.json into CSS, iOS, and Android.
Every theme is a fresh contrast obligation, automate the 4.5:1 check in CI.
13Where to go next
Tokens are the foundation layer of a design system, the next step is putting them to work inside well-structured, reusable components and choosing how you deliver the CSS.
A token needs to change in your three-tier system. Which tier do you edit?
Check your understanding
1. What problem do design tokens solve, per the article?
2. Why should components reference only semantic (or component) tokens and never primitives directly?
Frequently asked questions
What exactly is a design token?
A design token is a named, reusable design decision such as a color, spacing step, font size, or shadow, stored once and referenced everywhere. Change the token and every component pointing at it updates at once, so the hex code lives in exactly one place.
What are the three tiers of tokens?
Mature systems layer tokens into primitives, semantic tokens, and component tokens, where each tier references the one before it. For example, a button background points at a primary color, which points at a raw blue value, and that indirection is what makes dark mode and rebrands cheap.
How does dark mode work without editing components?
Components reference only semantic or component tokens, never primitives, so they stay theme-agnostic. Theming becomes a matter of re-pointing the semantic tokens in a new scope, typically by setting a data-theme attribute on the html element and overriding the semantic layer underneath it.
Why use CSS custom properties for tokens?
CSS custom properties cascade, inherit, can be redefined per scope, and update live with no rebuild, which makes them the natural runtime home for tokens. You define primitives and semantics on :root and override the semantic layer per theme.
Was this article helpful?
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.