3-Tier Design Token Architecture: From Figma to Themes
Every design system starts simple. A few CSS variables: --primary, --secondary, --background. Clean.
Then requirements arrive. Dark mode. Enterprise branding. A “Carbon” theme for one client. Now you have --primary-dark-mode, --primary-enterprise, --button-bg-carbon-dark. Your 10 variables became 200. Changing a color means hunting through files. Adding a theme means touching everything.
The problem isn’t naming conventions. It’s architecture. You’re mixing three separate concerns into one flat namespace: what colors exist, how they’re used, and who’s using them.
+-----------------------------------------------------------+
| THE 3-TIER MODEL |
| |
| L3: THEMES (Who) |
| [data-theme="carbon"] { --color--primary--100: teal } |
| | overrides |
| v |
| L2: DECISIONS (How) |
| --button--bg: var(--color--primary--100) |
| | references |
| v |
| L1: OPTIONS (What) |
| --color--primary--100: #3B82F6 |
+-----------------------------------------------------------+
Components use L2. L3 overrides L1. Nobody skips layers.
The fix: separate these concerns into three layers. Components reference semantic tokens (L2). Themes override primitive values (L1). The layers don’t leak into each other.
The Core Idea
Separate what exists from how it’s used from who’s using it.
| Layer | Question | Example |
|---|---|---|
| L1: Options | What colors exist? | --color--primary--100: #3B82F6 |
| L2: Decisions | What’s the button background? | --button--bg: var(--color--primary--100) |
| L3: Themes | What’s primary for this brand? | [data-theme="carbon"] { --color--primary--100: teal } |
Components only use L2. Themes only override L1. The layers don’t leak.
Why Flat Tokens Fail
Starts clean:
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
}
Then requirements:
- Dark mode
- Enterprise brand colors
- Carbon theme needs teal buttons
Now:
:root {
--primary: #3b82f6;
--primary-dark-mode: #60a5fa;
--primary-enterprise: #10b981;
--button-bg-dark-mode: var(--primary-dark-mode);
--button-bg-enterprise: var(--primary-enterprise);
/* ... 200 more */
}
Combinatorial explosion. You’re encoding brand × mode × component in one namespace.
Layer 1: Options
Raw values. Named by what they ARE.
:root {
--color--primary--100: #3b82f6;
--color--primary--90: #2563eb;
--color--slate-dark--90: #0f121a;
--spacing--16: 1rem;
}
Rules:
- No semantic meaning (
--color--primary--100, not--color--button) - Hex values, rem values only
- Maps 1:1 to Figma variables
Layer 2: Decisions
Semantic tokens. Named by PURPOSE.
:root {
--button--bg--default: var(--color--primary--100);
--button--bg--hover: var(--color--primary--90);
--table-header--bg: var(--color--slate-dark--90);
}
Rules:
- Named by how it’s used (
--button--bg--hover) - Never contains hex values—only L1 references
- This is what components consume
The critical rule: L2 tokens must not contain hex values.
/* Wrong */
--button--bg: #3b82f6;
/* Right */
--button--bg: var(--color--primary--100);
Layer 3: Themes
Override L1 per brand. Components don’t change.
[data-theme="carbon"] {
--color--primary--100: #2dd4bf; /* teal */
--color--primary--90: #14b8a6;
}
[data-theme="enterprise"] {
--color--primary--100: #10b981; /* green */
}
Adding a theme = adding a CSS block. Zero component changes.
The Payoff
Same button, four themes:
.button {
background: var(--button--bg--default);
color: var(--button--text--default);
}
.button:hover {
background: var(--button--bg--hover);
}
- Default: blue
- Carbon: teal
- Enterprise: green
- High contrast: yellow
Component doesn’t know themes exist.
Directory Structure
design-tokens/
├── 1-options/
│ ├── colors.css
│ └── spacing.css
├── 2-decisions/
│ └── by-component/
│ ├── button.css
│ └── table.css
└── 3-themes/
└── brands.css
Figma Mapping
| Figma | Token Layer |
|---|---|
| Primitive variables | L1 Options |
| Semantic variables | L2 Decisions |
| Modes | L3 Themes |
Export from Figma → Style Dictionary → CSS variables. Designer changes color → L1 updates → everything follows.
When to Skip This
- 5 colors, no themes: Overkill. Use flat variables.
- Design still changing: Premature abstraction. Stabilize first.
- No Figma parity: L1 won’t match design files.
Works when you have multiple themes or brands and want component code to be theme-agnostic.
Red Flags
- L2 tokens with hex values → Fix immediately
- Components using L1 directly → Should use L2
- L3 overriding L2 → Should override L1 only
Results
Two design systems:
- Theme PRs trivial (add CSS block, done)
- Figma variable names match L1 exactly
- Debugging easier (inspect shows
var(--button--bg), not hex) - Onboarding: 15 minutes to understand the system
More files, less complexity.
Next
Same layered thinking, applied to components: Configuration-Driven React Components.
Stop naming tokens by color. Name them by purpose. Let themes handle the rest.