You have three CSS variables. Now you need 40 components to use them consistently. Here's the token architecture that scales without breaking.
The problem with --color-dominant, --color-secondary, and --color-accent at scale: components start referencing them directly. A button uses var(--color-accent) for its background. A sidebar uses var(--color-secondary). A card uses var(--color-dominant). Then you want a dark mode variant, or a white-label theme, and you're hunting through 40 components to find every place a color is hardcoded.
The solution is three layers of abstraction between a hex value and a component.
Generate your starting palette at sixtythirtyten.co →
The three-layer model
Layer 1 — Primitives: Raw hex values with no semantic meaning. --primitive-blue-500: #3b82f6. These are the source of truth for your palette.
Layer 2 — Semantic tokens: Role names that map to primitives. --color-interactive: var(--primitive-blue-500). These tokens carry meaning: "this is the interactive color," not "this is blue."
Layer 3 — Component tokens: Specific surface names that map to semantic tokens. --button-primary-bg: var(--color-interactive). These tokens say exactly where a color is used.
When you change a theme, you only change the primitive values. Semantic and component tokens propagate automatically.
Layer 1: Primitives
Start by defining your palette as named primitive tokens. The names reference your design system's scale, not their visual meaning:
:root {
/* Primitives — palette scale */
--primitive-slate-50: #f8fafc;
--primitive-slate-200: #e2e8f0;
--primitive-slate-800: #1e293b;
--primitive-blue-500: #3b82f6;
--primitive-blue-700: #1d4ed8;
--primitive-white: #ffffff;
}
These values come directly from your 60-30-10 palette. If you're using sixtythirtyten.co, the generator outputs the hex values you paste here.
Primitives are never referenced by components directly. They exist only to feed semantic tokens.
Layer 2: Semantic tokens
Semantic tokens name the role, not the color. A developer reading --color-bg-primary understands the intent without knowing the hex value:
:root {
/* Semantic tokens — role layer */
--color-bg-primary: var(--primitive-slate-50);
--color-bg-secondary: var(--primitive-slate-800);
--color-interactive: var(--primitive-blue-500);
--color-interactive-hover: var(--primitive-blue-700);
--color-text-primary: var(--primitive-slate-800);
--color-text-on-dark: var(--primitive-slate-50);
--color-border: var(--primitive-slate-200);
}
Notice that --color-bg-secondary and --color-text-primary both point to --primitive-slate-800. That's fine — the same hex value can serve two semantic roles. When the palette changes, both update from one edit at the primitive level.
Layer 3: Component tokens
Component tokens are the most specific layer. They name the exact surface being colored:
:root {
/* Component tokens — surface layer */
--button-primary-bg: var(--color-interactive);
--button-primary-bg-hover: var(--color-interactive-hover);
--button-primary-text: var(--color-text-on-dark);
--sidebar-bg: var(--color-bg-secondary);
--sidebar-text: var(--color-text-on-dark);
--sidebar-item-active-bg: var(--color-interactive);
--card-bg: var(--color-bg-primary);
--card-border: var(--color-border);
--card-text: var(--color-text-primary);
--table-header-bg: var(--color-bg-secondary);
--table-header-text: var(--color-text-on-dark);
--table-row-bg: var(--color-bg-primary);
}
Components now reference only component tokens:
// Button.tsx
<button
style={{
backgroundColor: "var(--button-primary-bg)",
color: "var(--button-primary-text)",
}}
>
Save
</button>
Or with Tailwind CSS variables:
<button className="bg-[var(--button-primary-bg)] text-[var(--button-primary-text)]">
Save
</button>
Why three layers: the propagation benefit
Here's what makes this architecture worth the setup cost.
Scenario: you want a dark mode.
Without token layers, dark mode requires overriding colors across every component. With three layers, you override only the primitives:
@media (prefers-color-scheme: dark) {
:root {
/* Swap two primitives — everything else propagates */
--primitive-slate-50: #0f172a;
--primitive-slate-800: #f8fafc;
}
}
Because semantic tokens reference primitives, and component tokens reference semantic tokens, both layers update automatically. Your sidebar stays --sidebar-bg: var(--color-bg-secondary) — which now resolves to #f8fafc in dark mode because --primitive-slate-800 now resolves to #f8fafc.
Scenario: white-label theming.
A customer wants their brand blue (#2563eb) instead of your default blue. You expose only the primitive:
.theme-customer-a {
--primitive-blue-500: #2563eb;
--primitive-blue-700: #1d4ed8;
}
Every button, link, and active state in that theme scope updates automatically.
Tailwind v4 integration
In Tailwind v4, map your semantic tokens to @theme variables so they're available as utility classes:
@theme {
--color-bg-primary: var(--primitive-slate-50);
--color-bg-secondary: var(--primitive-slate-800);
--color-interactive: var(--primitive-blue-500);
--color-text-primary: var(--primitive-slate-800);
--color-text-on-dark: var(--primitive-slate-50);
--color-border: var(--primitive-slate-200);
}
This gives you classes like bg-bg-primary, text-interactive, and border-border alongside your component token inline styles.
When to add a layer
The three-layer model is not always the right depth:
- Small project (single theme, under 20 components): Stop at semantic tokens. Skip component tokens — they add overhead without enough payoff.
- Multi-theme or white-label product: All three layers. The primitive → semantic → component chain pays off at theming time.
- Design system at scale (50+ components): Consider a fourth layer for variant tokens (e.g.,
--button-danger-bg: var(--color-status-error)).
Start with what your project actually needs today. The layers can be added incrementally — adding component tokens later doesn't break the semantic layer.
What to do next
Use sixtythirtyten.co to generate your three base colors, then paste them in as primitive tokens to start the chain.
For the dashboard-specific palette decisions (status colors, chart palette, sidebar contrast), see SaaS Dashboard Color Palette: Extending 60-30-10 with Status and Chart Colors.
For the CSS variables foundation that feeds Layer 1 primitives, see CSS Variables Color System Cheat Sheet.
For dark mode propagation through the token layers, see 60-30-10 Dark Mode Color Palette.
For the full 60-30-10 rule, see The 60-30-10 Rule: A Developer's Guide to UI Color Balance.
Three primitives. Semantic roles. Component surfaces. One edit at the top propagates everywhere.