Skip to content

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.

LayerQuestionExample
L1: OptionsWhat colors exist?--color--primary--100: #3B82F6
L2: DecisionsWhat’s the button background?--button--bg: var(--color--primary--100)
L3: ThemesWhat’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

FigmaToken Layer
Primitive variablesL1 Options
Semantic variablesL2 Decisions
ModesL3 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.