CSS variables are easy to create.
A maintainable CSS variable system is harder.
Many projects begin with a few variables:
:root {
--blue: #2563eb;
--gray: #64748b;
--light-gray: #f1f5f9;
--dark-gray: #0f172a;
}
Then new variables appear whenever a component needs something:
:root {
--button-blue: #2563eb;
--card-gray: #f8fafc;
--input-border: #cbd5e1;
--error-red: #dc2626;
--success-green: #15803d;
--disabled-gray: #94a3b8;
}
This works for a while.
But eventually the variable list becomes difficult to understand.
You may have:
- several variables for nearly identical colors
- component names mixed with color names
- inconsistent hover and disabled states
- dark-mode overrides scattered across multiple files
- hardcoded values inside components
- no clear rule for adding the next token
The problem is not that you need more variables.
The problem is that the variables do not follow a structure.
This article presents a small semantic token system for common interface elements:
- page backgrounds
- cards and surfaces
- normal and muted text
- borders
- primary buttons
- secondary buttons
- forms
- danger states
- success states
- disabled states
The goal is not to create hundreds of design tokens.
The goal is to create a small system that remains understandable as the interface grows.
Start with roles, not components
A common mistake is creating a new variable for every component.
:root {
--card-background: #ffffff;
--modal-background: #ffffff;
--dropdown-background: #ffffff;
--form-background: #ffffff;
}
These may all represent the same underlying role: a surface placed above the page background.
Instead, start with shared semantic roles:
:root {
--color-background: #f8fafc;
--color-surface: #ffffff;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-border: #cbd5e1;
}
Now cards, forms, modals, and dropdowns can all use the same surface token until they genuinely need different behavior.
.card,
.form-panel,
.modal,
.dropdown {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
This gives the system a stable foundation.
A practical starter structure
Here is a small token set for a typical application:
:root {
/* Page and surfaces */
--color-background: #f8fafc;
--color-surface: #ffffff;
--color-surface-hover: #f1f5f9;
/* Text */
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-disabled: #94a3b8;
/* Borders and focus */
--color-border: #cbd5e1;
--color-border-strong: #94a3b8;
--color-focus-ring: #3b82f6;
/* Primary action */
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-pressed: #1e40af;
--color-on-primary: #ffffff;
/* Secondary action */
--color-secondary: #e2e8f0;
--color-secondary-hover: #cbd5e1;
--color-secondary-pressed: #94a3b8;
--color-on-secondary: #0f172a;
/* Danger */
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-danger-surface: #fef2f2;
--color-danger-border: #fca5a5;
--color-on-danger: #ffffff;
/* Success */
--color-success: #15803d;
--color-success-surface: #f0fdf4;
--color-success-border: #86efac;
--color-on-success: #ffffff;
/* Disabled */
--color-disabled-background: #e2e8f0;
--color-disabled-text: #94a3b8;
--color-disabled-border: #cbd5e1;
}
The names describe what each value does.
They do not describe the exact color currently assigned to it.
That makes the values easier to replace during:
- dark mode
- brand redesigns
- accessibility improvements
- product customization
Page background and surface
Most applications need at least two background roles.
:root {
--color-background: #f8fafc;
--color-surface: #ffffff;
}
--color-background is the page-level background.
--color-surface is used for elements placed above it, such as:
- cards
- forms
- panels
- dialogs
- dropdowns
- navigation containers
body {
margin: 0;
background: var(--color-background);
color: var(--color-text);
}
.card {
background: var(--color-surface);
}
Using two roles creates visual separation without requiring every component to invent its own background token.
Normal text and muted text
Interfaces usually need more than one text emphasis level.
:root {
--color-text: #0f172a;
--color-text-muted: #64748b;
}
Use normal text for important content:
.card__title {
color: var(--color-text);
}
Use muted text for secondary information:
.card__description,
.form-help,
.metadata {
color: var(--color-text-muted);
}
Muted text should still remain readable.
It should communicate lower emphasis, not become nearly invisible.
Avoid using opacity as the only way to create muted text:
/* Less predictable */
.muted {
opacity: 0.5;
}
Opacity affects the entire element and changes its resulting contrast depending on the background.
A dedicated token provides more control:
.muted {
color: var(--color-text-muted);
}
Borders
A simple interface may only need one border token:
:root {
--color-border: #cbd5e1;
}
Use it across cards, inputs, dividers, and panels:
.card {
border: 1px solid var(--color-border);
}
.input {
border: 1px solid var(--color-border);
}
.divider {
border-top: 1px solid var(--color-border);
}
You can add a stronger border role when needed:
:root {
--color-border: #cbd5e1;
--color-border-strong: #94a3b8;
}
For example:
.input:hover {
border-color: var(--color-border-strong);
}
The names explain the relationship between the two values without tying them to gray shades.
Primary buttons
A button usually needs more than one background value.
At minimum, consider:
- default
- hover
- pressed
- foreground text
- disabled
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-pressed: #1e40af;
--color-on-primary: #ffffff;
}
Then the component becomes:
.button-primary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1rem;
border: 1px solid transparent;
border-radius: 0.5rem;
background: var(--color-primary);
color: var(--color-on-primary);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.button-primary:hover {
background: var(--color-primary-hover);
}
.button-primary:active {
background: var(--color-primary-pressed);
}
The button does not know that the primary color is blue.
It only knows that it represents a primary action.
Why --color-on-primary matters
This is common:
.button-primary {
background: var(--color-primary);
color: white;
}
But white may not work on every primary color.
A light yellow or cyan background may need dark text.
Define the foreground intentionally:
:root {
--color-primary: #facc15;
--color-on-primary: #111827;
}
The component CSS remains unchanged:
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
This pattern is useful for all important filled states:
--color-on-primary
--color-on-secondary
--color-on-danger
--color-on-success
The background and foreground should be treated as a pair.
Secondary buttons
A secondary button should have lower visual emphasis than the primary action.
:root {
--color-secondary: #e2e8f0;
--color-secondary-hover: #cbd5e1;
--color-secondary-pressed: #94a3b8;
--color-on-secondary: #0f172a;
}
.button-secondary {
padding: 0.625rem 1rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-secondary);
color: var(--color-on-secondary);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.button-secondary:hover {
background: var(--color-secondary-hover);
}
.button-secondary:active {
background: var(--color-secondary-pressed);
}
You can also create an outline-style secondary button:
.button-secondary-outline {
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
}
.button-secondary-outline:hover {
background: var(--color-surface-hover);
}
This reuses existing semantic roles rather than introducing unnecessary new colors.
Cards
A basic card usually needs:
- surface
- text
- muted text
- border
.card {
padding: 1.25rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
}
.card__title {
margin: 0;
color: var(--color-text);
}
.card__description {
margin-top: 0.5rem;
color: var(--color-text-muted);
}
An interactive card can use the surface hover token:
.card--interactive {
cursor: pointer;
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.card--interactive:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-strong);
}
The card is not tied to white, gray-50, or another palette value.
The theme determines what a surface looks like.
Form inputs
Forms require careful treatment because they communicate several states:
- default
- hover
- focus
- invalid
- valid
- disabled
A basic input can use the shared surface, text, muted text, and border tokens.
.form-field {
display: grid;
gap: 0.375rem;
}
.form-label {
color: var(--color-text);
font-weight: 600;
}
.form-input {
width: 100%;
box-sizing: border-box;
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.form-input::placeholder {
color: var(--color-text-muted);
}
.form-input:hover {
border-color: var(--color-border-strong);
}
Focus state
Do not remove focus outlines without providing a replacement.
A shared focus token keeps focus behavior consistent:
:root {
--color-focus-ring: #3b82f6;
}
.form-input:focus-visible,
.button-primary:focus-visible,
.button-secondary:focus-visible {
outline: 3px solid color-mix(
in srgb,
var(--color-focus-ring) 35%,
transparent
);
outline-offset: 2px;
}
A simpler fallback is:
.form-input:focus-visible {
outline: 3px solid var(--color-focus-ring);
outline-offset: 2px;
}
You can also change the input border:
.form-input:focus {
border-color: var(--color-focus-ring);
}
The important point is that focus should have a dedicated semantic role.
It should not be borrowed randomly from another component.
Danger state
Danger can represent:
- destructive buttons
- form validation errors
- alerts
- failed operations
One danger color is often not enough.
A filled danger button and a light error message require different surfaces.
:root {
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-danger-surface: #fef2f2;
--color-danger-border: #fca5a5;
--color-on-danger: #ffffff;
}
Destructive button
.button-danger {
padding: 0.625rem 1rem;
border: 1px solid transparent;
border-radius: 0.5rem;
background: var(--color-danger);
color: var(--color-on-danger);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.button-danger:hover {
background: var(--color-danger-hover);
}
Invalid form field
.form-input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.form-error {
color: var(--color-danger);
}
Error alert
.alert-danger {
padding: 1rem;
background: var(--color-danger-surface);
color: var(--color-danger);
border: 1px solid var(--color-danger-border);
border-radius: 0.5rem;
}
The same danger role supports several component patterns without hardcoding red values inside each component.
Success state
Success states can represent:
- completed actions
- valid form fields
- positive notifications
- successful uploads or payments
:root {
--color-success: #15803d;
--color-success-surface: #f0fdf4;
--color-success-border: #86efac;
--color-on-success: #ffffff;
}
Valid input
.form-input[aria-invalid="false"] {
border-color: var(--color-success);
}
Use this carefully.
Not every untouched input should appear successful merely because it is not invalid.
Apply it only after validation has actually occurred.
Success message
.alert-success {
padding: 1rem;
background: var(--color-success-surface);
color: var(--color-success);
border: 1px solid var(--color-success-border);
border-radius: 0.5rem;
}
Filled success action
.button-success {
background: var(--color-success);
color: var(--color-on-success);
}
Again, the component reads the role rather than a specific green shade.
Disabled state
Disabled UI is frequently implemented with opacity:
button:disabled {
opacity: 0.5;
}
This is simple, but it may produce unpredictable contrast because the final appearance depends on the background behind the element.
A more controlled approach uses dedicated tokens:
:root {
--color-disabled-background: #e2e8f0;
--color-disabled-text: #94a3b8;
--color-disabled-border: #cbd5e1;
}
button:disabled,
input:disabled {
background: var(--color-disabled-background);
color: var(--color-disabled-text);
border-color: var(--color-disabled-border);
cursor: not-allowed;
}
For buttons:
.button-primary:disabled,
.button-secondary:disabled,
.button-danger:disabled {
background: var(--color-disabled-background);
color: var(--color-disabled-text);
border-color: var(--color-disabled-border);
cursor: not-allowed;
}
A disabled state should look unavailable without becoming unreadable.
Also remember that visual styling is not enough.
Use the actual HTML disabled attribute when the control should not be interactive:
<button type="button" disabled>
Submit
</button>
A complete light-mode token set
Here is the full starter structure in one place:
:root {
color-scheme: light;
/* Page and surfaces */
--color-background: #f8fafc;
--color-surface: #ffffff;
--color-surface-hover: #f1f5f9;
/* Text */
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-disabled: #94a3b8;
/* Borders and focus */
--color-border: #cbd5e1;
--color-border-strong: #94a3b8;
--color-focus-ring: #3b82f6;
/* Primary */
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-pressed: #1e40af;
--color-on-primary: #ffffff;
/* Secondary */
--color-secondary: #e2e8f0;
--color-secondary-hover: #cbd5e1;
--color-secondary-pressed: #94a3b8;
--color-on-secondary: #0f172a;
/* Danger */
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-danger-surface: #fef2f2;
--color-danger-border: #fca5a5;
--color-on-danger: #ffffff;
/* Success */
--color-success: #15803d;
--color-success-surface: #f0fdf4;
--color-success-border: #86efac;
--color-on-success: #ffffff;
/* Disabled */
--color-disabled-background: #e2e8f0;
--color-disabled-text: #94a3b8;
--color-disabled-border: #cbd5e1;
}
Add dark mode without changing components
Dark mode should override values, not rewrite component styles.
:root[data-theme="dark"] {
color-scheme: dark;
/* Page and surfaces */
--color-background: #020617;
--color-surface: #0f172a;
--color-surface-hover: #1e293b;
/* Text */
--color-text: #f8fafc;
--color-text-muted: #94a3b8;
--color-text-disabled: #64748b;
/* Borders and focus */
--color-border: #334155;
--color-border-strong: #475569;
--color-focus-ring: #60a5fa;
/* Primary */
--color-primary: #60a5fa;
--color-primary-hover: #93c5fd;
--color-primary-pressed: #bfdbfe;
--color-on-primary: #020617;
/* Secondary */
--color-secondary: #1e293b;
--color-secondary-hover: #334155;
--color-secondary-pressed: #475569;
--color-on-secondary: #f8fafc;
/* Danger */
--color-danger: #f87171;
--color-danger-hover: #fca5a5;
--color-danger-surface: #450a0a;
--color-danger-border: #991b1b;
--color-on-danger: #450a0a;
/* Success */
--color-success: #4ade80;
--color-success-surface: #052e16;
--color-success-border: #166534;
--color-on-success: #052e16;
/* Disabled */
--color-disabled-background: #1e293b;
--color-disabled-text: #64748b;
--color-disabled-border: #334155;
}
Your card CSS does not change:
.card {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
Your input CSS does not change:
.form-input {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
Your button CSS does not change:
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
The theme changes the values.
The components continue using the same semantic contract.
Do not create every possible token immediately
It is possible to create variables for everything:
--button-primary-background
--button-primary-background-hover
--button-primary-text
--card-background
--card-border
--input-background
--input-border
--input-placeholder
This can be useful in a large design system.
But it may be unnecessary for a small application.
Start with shared semantic roles:
--color-primary
--color-on-primary
--color-surface
--color-text
--color-text-muted
--color-border
Add component-level tokens only when a component needs to behave differently from the shared role.
For example:
:root {
--button-primary-background: var(--color-primary);
--button-primary-text: var(--color-on-primary);
}
Now the button can be customized independently later:
.button-primary {
background: var(--button-primary-background);
color: var(--button-primary-text);
}
This produces a layered system:
Palette → semantic roles → component roles
Most small projects can begin with the semantic layer and add component aliases gradually.
A simple rule for adding new variables
Before adding a new CSS variable, ask:
- Is this value reused?
- Does it represent a stable UI role?
- Does it need to change between themes?
- Is an existing semantic token already suitable?
- Am I naming its purpose or only its current appearance?
For example, this name describes appearance:
--light-gray
This name describes purpose:
--color-border
This name may be too component-specific too early:
--settings-page-card-border
This may be a better starting point:
--color-border
Use the most general stable role that still communicates intent clearly.
Complete component example
<section class="card">
<h2 class="card__title">Create account</h2>
<p class="card__description">
Enter your information to continue.
</p>
<form class="form">
<div class="form-field">
<label class="form-label" for="email">
Email address
</label>
<input
class="form-input"
id="email"
type="email"
placeholder="you@example.com"
/>
<p class="form-help">
We will not share your email.
</p>
</div>
<div class="form-actions">
<button class="button-secondary" type="button">
Cancel
</button>
<button class="button-primary" type="submit">
Create account
</button>
</div>
</form>
</section>
.card {
max-width: 32rem;
padding: 1.5rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
}
.card__title {
margin: 0;
}
.card__description {
margin: 0.5rem 0 1.5rem;
color: var(--color-text-muted);
}
.form {
display: grid;
gap: 1rem;
}
.form-field {
display: grid;
gap: 0.375rem;
}
.form-label {
font-weight: 600;
}
.form-input {
padding: 0.625rem 0.75rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font: inherit;
}
.form-input::placeholder,
.form-help {
color: var(--color-text-muted);
}
.form-input:hover {
border-color: var(--color-border-strong);
}
.form-input:focus-visible {
border-color: var(--color-focus-ring);
outline: 3px solid color-mix(
in srgb,
var(--color-focus-ring) 30%,
transparent
);
outline-offset: 2px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.button-primary,
.button-secondary {
padding: 0.625rem 1rem;
border-radius: 0.5rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.button-primary {
border: 1px solid transparent;
background: var(--color-primary);
color: var(--color-on-primary);
}
.button-primary:hover {
background: var(--color-primary-hover);
}
.button-primary:active {
background: var(--color-primary-pressed);
}
.button-secondary {
border: 1px solid var(--color-border);
background: var(--color-secondary);
color: var(--color-on-secondary);
}
.button-secondary:hover {
background: var(--color-secondary-hover);
}
.button-secondary:active {
background: var(--color-secondary-pressed);
}
.button-primary:disabled,
.button-secondary:disabled,
.form-input:disabled {
border-color: var(--color-disabled-border);
background: var(--color-disabled-background);
color: var(--color-disabled-text);
cursor: not-allowed;
}
The components use a small, shared vocabulary:
--color-background
--color-surface
--color-text
--color-text-muted
--color-border
--color-primary
--color-on-primary
--color-secondary
--color-danger
--color-success
--color-disabled-background
That vocabulary becomes the design contract for the application.
The bottom line
Do not create CSS variables randomly whenever a component needs a new color.
Start with a small semantic structure:
Page and surfaces
Text
Borders and focus
Primary and secondary actions
Danger and success states
Disabled states
Then let components use those shared roles.
.card {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
.form-error {
color: var(--color-danger);
}
The values can change between themes.
The components remain stable.
That is the purpose of a token system:
Not to create more variables, but to create a clear language that the entire interface can share.
Top comments (0)