DEV Community

Cover image for Your CSS variables need a vocabulary, not a collection of random colors
Hasan Sarwer
Hasan Sarwer

Posted on

Your CSS variables need a vocabulary, not a collection of random colors

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

--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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Use normal text for important content:

.card__title {
  color: var(--color-text);
}
Enter fullscreen mode Exit fullscreen mode

Use muted text for secondary information:

.card__description,
.form-help,
.metadata {
  color: var(--color-text-muted);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Borders

A simple interface may only need one border token:

:root {
  --color-border: #cbd5e1;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

You can add a stronger border role when needed:

:root {
  --color-border: #cbd5e1;
  --color-border-strong: #94a3b8;
}
Enter fullscreen mode Exit fullscreen mode

For example:

.input:hover {
  border-color: var(--color-border-strong);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

The component CSS remains unchanged:

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
Enter fullscreen mode Exit fullscreen mode

This pattern is useful for all important filled states:

--color-on-primary
--color-on-secondary
--color-on-danger
--color-on-success
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
.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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Focus state

Do not remove focus outlines without providing a replacement.

A shared focus token keeps focus behavior consistent:

:root {
  --color-focus-ring: #3b82f6;
}
Enter fullscreen mode Exit fullscreen mode
.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;
}
Enter fullscreen mode Exit fullscreen mode

A simpler fallback is:

.form-input:focus-visible {
  outline: 3px solid var(--color-focus-ring);
  outline-offset: 2px;
}
Enter fullscreen mode Exit fullscreen mode

You can also change the input border:

.form-input:focus {
  border-color: var(--color-focus-ring);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Invalid form field

.form-input[aria-invalid="true"] {
  border-color: var(--color-danger);
}

.form-error {
  color: var(--color-danger);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Valid input

.form-input[aria-invalid="false"] {
  border-color: var(--color-success);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Filled success action

.button-success {
  background: var(--color-success);
  color: var(--color-on-success);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
button:disabled,
input:disabled {
  background: var(--color-disabled-background);
  color: var(--color-disabled-text);
  border-color: var(--color-disabled-border);

  cursor: not-allowed;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Your card CSS does not change:

.card {
  background: var(--color-surface);
  color: var(--color-text);
  border-color: var(--color-border);
}
Enter fullscreen mode Exit fullscreen mode

Your input CSS does not change:

.form-input {
  background: var(--color-surface);
  color: var(--color-text);
  border-color: var(--color-border);
}
Enter fullscreen mode Exit fullscreen mode

Your button CSS does not change:

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Now the button can be customized independently later:

.button-primary {
  background: var(--button-primary-background);
  color: var(--button-primary-text);
}
Enter fullscreen mode Exit fullscreen mode

This produces a layered system:

Palette → semantic roles → component roles
Enter fullscreen mode Exit fullscreen mode

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:

  1. Is this value reused?
  2. Does it represent a stable UI role?
  3. Does it need to change between themes?
  4. Is an existing semantic token already suitable?
  5. Am I naming its purpose or only its current appearance?

For example, this name describes appearance:

--light-gray
Enter fullscreen mode Exit fullscreen mode

This name describes purpose:

--color-border
Enter fullscreen mode Exit fullscreen mode

This name may be too component-specific too early:

--settings-page-card-border
Enter fullscreen mode Exit fullscreen mode

This may be a better starting point:

--color-border
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
.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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)