Most design token setups I’ve seen accumulate debt fast. Someone adds --color-primary directly to a button, then a card, then half the site. When you change the color, you’re grepping for every usage instead of changing one definition. A stricter architecture fixes this.
The approach: three layers, each with a clear job. Palette → Semantic → Component.
The three-layer rule
Palette holds raw values. Nothing else.
--color-blue: #007aff;
--color-blue-dark: #0056b3;
--color-gray-200: #e5e7eb;Palette tokens never appear in component code. They exist only so the semantic layer has named values to reference.
Semantic is where meaning lives. The same palette color appears under many semantic names: different roles, same underlying value. Change the palette token once and every semantic token that references it updates automatically.
--fill-interactive: var(--color-blue);
--text-interactive: var(--color-blue);
--border-interactive: var(--color-blue);Component is an optional alias layer. It references semantic tokens and adds nothing new. --button-bg: var(--fill-interactive). Useful for consistency and overrides within a component family. Must never reference palette.
The core rule: UI never touches palette. Semantic is the single source of truth.
Separate tokens by visual model
Interactive elements fall into distinct visual models. A link changes its text color on hover. A button changes its background fill. These behave differently and respond to different CSS properties, so they deserve separate token families rather than one shared --interactive token.
For text-based interactions:
color: var(--text-interactive);For fill-based interactions:
background: var(--fill-interactive);The split lets you change one without affecting the other. How many categories you need depends on your UI. The principle is: token families should reflect visual behavior, not just element type.
Explicit states
States should never be computed or implied. No filter: brightness(0.9) on hover, no opacity: 0.5 for disabled. Every state is a named token with a deliberate value:
--fill-interactive: var(--color-blue);
--fill-interactive-hover: var(--color-blue-dark);
--fill-interactive-pressed: var(--color-blue-dark);
--fill-interactive-focus: rgb(0 122 255 / 25%);
--fill-interactive-disabled: var(--color-gray-300);When states are defined explicitly, they’re also reviewable. A PR that changes --fill-interactive-focus is readable. A brightness() hack buried in a component is not.
Themes change semantics, not structure
A theme is a redefinition of the semantic layer, nothing else. Components don’t change between light and dark mode. Only the values behind the semantic tokens do.
/* Light (default) */
--fill-background: var(--color-white);
--text-primary: #1d1d1f;
--elevation-1: 0 2px 4px rgb(0 0 0 / 8%);
/* Dark */
--fill-background: var(--color-gray-900);
--text-primary: #f5f5f7;
--elevation-1: 0 2px 6px rgb(0 0 0 / 30%);Themes can also adjust values for context. Elevation shadows need to be heavier in dark mode because the same opacity that reads as depth on white becomes invisible on a dark gray surface.
Role-based typography
Font size tokens should be named by role, not by value. --font-size-heading is more stable than --font-size-24 because it describes intent. When the heading size changes from 24px to 22px, the token name stays correct and nothing else needs updating.
Each role should have a fixed pairing: a font size, a line height, and a font family. This pairing is a contract.
/* The contract: heading uses secondary font, tight line height */
--font-size-heading: 1.5rem;
--line-height-tight: 1.2;
--font-secondary: inter, system-ui, sans-serif;No ad-hoc sizes in components. The scale is the API.
Typography should be theme-agnostic. Themes affect color and contrast. Font sizes, line heights, and font families stay fixed across themes.
System colors are strictly functional
Success, warning, danger, and info tokens are for feedback and validation states only. Not decoration. Not branding accents.
When system colors are reserved for their intended purpose, violations become obvious. If you see a system color used as a section background or a highlight, it reads as wrong immediately. Because it is.
Why the layers matter
The structure solves a concrete problem. Without it, changing a brand color means auditing every component. With it, you change one palette token and everything that inherits from it updates.
The layers also encode intent. When you read var(--fill-interactive-disabled) in a component, you know what it does and why. When you read var(--color-gray-300), you don’t.
That’s the real value of the system: not the tokens themselves, but the constraints that tell you where each value belongs.
The rules
- UI never touches palette.
- Semantic is the single source of truth.
- Component tokens are optional convenience, not logic.
- States are always explicit. No computed hover, no implicit disabled.
- System colors are strictly functional, never decorative.
- Typography is role-based; themes change color, not type scale.