A button does not need to know that it is blue.
A card does not need to know that its background is gray.
A heading does not need to know that its text is #111827.
Components need to know what a value means, not what the value currently looks like.
Yet many CSS variable systems start like this:
:root {
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-900: #111827;
--red-600: #dc2626;
}
Then component styles use those variables directly:
.button {
background: var(--blue-600);
color: white;
}
.card {
background: var(--gray-50);
color: var(--gray-900);
}
.error-message {
color: var(--red-600);
}
This works.
Until you add dark mode.
Or change the brand color.
Or reuse the same component in a different product.
Color-based variables describe appearance.
Semantic variables describe purpose.
That difference becomes important as soon as the UI changes.
The problem with color-name variables
Consider this button:
.button-primary {
background: var(--blue-600);
color: white;
}
The component is now tied to blue.
But what if the product is redesigned and the brand color becomes purple?
You have several bad options.
Option 1: change the value of --blue-600
:root {
--blue-600: #7c3aed;
}
Now a variable named blue contains purple.
The UI may work, but the token name is lying.
Option 2: rename every usage
.button-primary {
background: var(--purple-600);
}
Now you need to find every reference to --blue-600 and decide whether it should change.
Some usages may represent:
- brand color
- links
- selected states
- charts
- informational messages
They may not all have the same purpose.
Option 3: add more aliases
:root {
--brand-color: var(--blue-600);
}
This is better, but it means the component should probably have used a semantic variable from the beginning.
Color names answer the wrong question
A token like this answers:
What color is this?
--blue-600
A semantic token answers:
What role does this color play?
--color-primary
That difference matters.
The visual value may change.
The role usually stays stable.
A primary action may be blue today and purple next year.
It is still the primary action.
A semantic variable structure
Instead of exposing palette values directly to components, define variables around UI roles.
:root {
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--color-primary: #2563eb;
--color-on-primary: #ffffff;
--color-danger: #dc2626;
--color-on-danger: #ffffff;
--color-success: #15803d;
--color-on-success: #ffffff;
}
Now components describe intent:
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
.card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.error-message {
color: var(--color-danger);
}
The component no longer knows whether the primary color is blue, purple, or green.
It only knows that it is using the primary role.
Dark mode becomes easier
With color-name variables, dark mode often becomes confusing.
Imagine this:
:root {
--gray-50: #f9fafb;
--gray-900: #111827;
}
.card {
background: var(--gray-50);
color: var(--gray-900);
}
For dark mode, should --gray-50 become dark?
[data-theme="dark"] {
--gray-50: #111827;
--gray-900: #f9fafb;
}
Now the names no longer match the values.
--gray-50 contains a very dark color.
--gray-900 contains a very light color.
The variable names are based on the original palette, not the role in the interface.
Semantic variables avoid this problem.
:root {
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-text: #0f172a;
--color-border: #e2e8f0;
}
:root[data-theme="dark"] {
--color-background: #020617;
--color-surface: #0f172a;
--color-text: #f8fafc;
--color-border: #1e293b;
}
The meaning stays correct in both modes:
.card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
The values change.
The component does not.
Separate palette tokens from semantic tokens
This does not mean palette tokens are always bad.
A useful token system can have two layers.
Layer 1: palette tokens
These describe raw colors.
:root {
--palette-blue-500: #3b82f6;
--palette-blue-600: #2563eb;
--palette-blue-700: #1d4ed8;
--palette-slate-50: #f8fafc;
--palette-slate-900: #0f172a;
--palette-red-600: #dc2626;
}
Layer 2: semantic tokens
These map raw colors to UI meaning.
:root {
--color-background: var(--palette-slate-50);
--color-text: var(--palette-slate-900);
--color-primary: var(--palette-blue-600);
--color-primary-hover: var(--palette-blue-700);
--color-danger: var(--palette-red-600);
}
Components should use the semantic layer:
.button-primary {
background: var(--color-primary);
}
.button-primary:hover {
background: var(--color-primary-hover);
}
The palette is an implementation detail.
The semantic tokens are the component contract.
Why --color-on-primary matters
Many systems define the button background but hardcode the text:
.button-primary {
background: var(--color-primary);
color: white;
}
That assumes white always has enough contrast.
It may not.
A light yellow, cyan, or pastel primary color may need dark text instead.
Define the foreground as its own semantic token:
:root {
--color-primary: #2563eb;
--color-on-primary: #ffffff;
}
Then:
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
For a light primary color:
:root {
--color-primary: #facc15;
--color-on-primary: #111827;
}
The component CSS stays the same.
This pattern is useful for:
--color-on-primary
--color-on-secondary
--color-on-danger
--color-on-success
--color-on-warning
--color-on-info
Every important background color should have an intentional foreground color.
State variables should also be semantic
A common approach is to derive hover behavior inside the component:
.button-primary:hover {
filter: brightness(0.9);
}
That may be acceptable for a small project, but it gives less control over contrast and visual consistency.
A more systematic approach is:
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-pressed: #1e40af;
--color-primary-disabled: #93c5fd;
}
Then:
.button-primary {
background: var(--color-primary);
}
.button-primary:hover {
background: var(--color-primary-hover);
}
.button-primary:active {
background: var(--color-primary-pressed);
}
.button-primary:disabled {
background: var(--color-primary-disabled);
}
Again, the names describe roles, not exact colors.
A practical starter token set
You do not need hundreds of tokens.
For a small application, this is a reasonable starting point:
:root {
/* Page and surfaces */
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-surface-elevated: #ffffff;
/* Text */
--color-text: #0f172a;
--color-text-muted: #64748b;
/* Structure */
--color-border: #e2e8f0;
--color-focus-ring: #3b82f6;
/* Brand */
--color-primary: #2563eb;
--color-on-primary: #ffffff;
--color-primary-hover: #1d4ed8;
--color-primary-pressed: #1e40af;
--color-primary-disabled: #93c5fd;
/* Semantic intent */
--color-danger: #dc2626;
--color-on-danger: #ffffff;
--color-success: #15803d;
--color-on-success: #ffffff;
--color-warning: #a16207;
--color-on-warning: #ffffff;
--color-info: #0369a1;
--color-on-info: #ffffff;
}
Dark mode can override the same roles:
:root[data-theme="dark"] {
--color-background: #020617;
--color-surface: #0f172a;
--color-surface-elevated: #1e293b;
--color-text: #f8fafc;
--color-text-muted: #94a3b8;
--color-border: #1e293b;
--color-focus-ring: #60a5fa;
--color-primary: #60a5fa;
--color-on-primary: #020617;
--color-primary-hover: #93c5fd;
--color-primary-pressed: #bfdbfe;
--color-primary-disabled: #334155;
--color-danger: #f87171;
--color-on-danger: #450a0a;
--color-success: #4ade80;
--color-on-success: #052e16;
--color-warning: #facc15;
--color-on-warning: #422006;
--color-info: #38bdf8;
--color-on-info: #082f49;
}
Components continue using the same variables.
Before and after
Before
.profile-card {
background: var(--gray-50);
color: var(--gray-900);
border: 1px solid var(--gray-200);
}
.profile-card__link {
color: var(--blue-600);
}
.profile-card__delete {
background: var(--red-600);
color: white;
}
After
.profile-card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.profile-card__link {
color: var(--color-primary);
}
.profile-card__delete {
background: var(--color-danger);
color: var(--color-on-danger);
}
The second version is easier to understand.
You can read it as UI intent:
- this is a surface
- this is normal text
- this is a border
- this is a primary action
- this is a dangerous action
Semantic variables also help redesigns
Imagine changing the brand color from blue to purple.
With semantic variables, the redesign may require changing one mapping:
:root {
--color-primary: #7c3aed;
--color-primary-hover: #6d28d9;
--color-primary-pressed: #5b21b6;
}
Components do not need to change.
.button-primary {
background: var(--color-primary);
}
The same applies to redesigning surfaces, borders, typography contrast, or state colors.
The token layer absorbs the visual change.
Semantic does not mean vague
Names like these are useful:
--color-background
--color-surface
--color-text
--color-text-muted
--color-border
--color-primary
--color-on-primary
--color-danger
Names like these may be too vague:
--color-one
--color-two
--main-color
--nice-gray
--dark-color
A semantic name should describe a stable role.
It should answer:
Where or why is this value used?
Avoid component-specific tokens too early
You could create:
--button-background
--button-text
--card-background
--modal-background
--navbar-background
This can become useful in a large design system, but it may be too specific for a small project.
Start with shared semantic roles:
--color-primary
--color-on-primary
--color-surface
--color-text
--color-border
Then introduce component-level aliases only when components genuinely need different behavior.
For example:
:root {
--button-primary-background: var(--color-primary);
--button-primary-text: var(--color-on-primary);
}
That gives you a three-layer structure:
Palette → semantic token → component token
Use the extra layer only when it solves a real need.
A useful mental model
Think of the layers like this:
Palette token:
What color is available?
Semantic token:
What role does the color play?
Component token:
How does this specific component use that role?
Example:
:root {
--palette-blue-600: #2563eb;
--color-primary: var(--palette-blue-600);
--button-primary-background: var(--color-primary);
}
Then:
.button-primary {
background: var(--button-primary-background);
}
For many projects, the middle layer is enough.
The bottom line
Color names describe the current appearance.
Semantic names describe the purpose.
This:
background: var(--blue-600);
color: var(--gray-50);
ties the component to today's palette.
This:
background: var(--color-primary);
color: var(--color-on-primary);
ties the component to a stable design role.
That makes dark mode easier.
It makes redesigns easier.
It makes components easier to reuse.
And it makes the CSS easier to understand.
Use palette values to build your theme.
Use semantic variables inside your components.
Top comments (0)