DEV Community

Cover image for Stop Naming CSS Variables After Colors
Hasan Sarwer
Hasan Sarwer

Posted on

Stop Naming CSS Variables After Colors

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

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

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

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

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

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

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

A semantic token answers:

What role does this color play?
Enter fullscreen mode Exit fullscreen mode
--color-primary
Enter fullscreen mode Exit fullscreen mode

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

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

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

For dark mode, should --gray-50 become dark?

[data-theme="dark"] {
  --gray-50: #111827;
  --gray-900: #f9fafb;
}
Enter fullscreen mode Exit fullscreen mode

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

The meaning stays correct in both modes:

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

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

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

Components should use the semantic layer:

.button-primary {
  background: var(--color-primary);
}

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

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

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

Then:

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

For a light primary color:

:root {
  --color-primary: #facc15;
  --color-on-primary: #111827;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

Components do not need to change.

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

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

Names like these may be too vague:

--color-one
--color-two
--main-color
--nice-gray
--dark-color
Enter fullscreen mode Exit fullscreen mode

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

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

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

That gives you a three-layer structure:

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

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

Example:

:root {
  --palette-blue-600: #2563eb;

  --color-primary: var(--palette-blue-600);

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

Then:

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

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

ties the component to today's palette.

This:

background: var(--color-primary);
color: var(--color-on-primary);
Enter fullscreen mode Exit fullscreen mode

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)