← Back to Blog
CSSCustom PropertiesDesign SystemsFrontend

CSS Color Variables: A Complete Guide

CSS color variables are custom properties that store color values in CSS so you can reuse and update them through the cascade. In practice, that means you define a token like --color-text once, use it everywhere, and change one declaration instead of hunting through fifty hex codes later.

That matters most when a site grows past a few components. A one-off landing page can survive hardcoded colors. A real product with dark mode, status states, and shared components gets messy fast. This guide shows how to structure color variables, when to use semantic tokens, how fallbacks work, and how to update themes at runtime. If you need to convert between formats while building a palette, the Color Converter helps with the HEX, RGB, and HSL side of it.

What is a CSS color variable?

CSS custom properties are properties whose names start with --, and color variables are just custom properties that hold color values. They are regular CSS properties, so they participate in the cascade, inherit by default, and can be consumed anywhere a normal color value can appear with var().

Here is the smallest useful example:

:root {
  --color-brand: #0ea5e9;
  --color-surface: #09090b;
  --color-text: #f4f4f5;
}

button {
  background: var(--color-brand);
  color: var(--color-text);
}

If you have only used Sass variables before, this is the important distinction: Sass variables disappear at build time, but CSS variables stay live in the browser. You can override them per component, per theme, per breakpoint, or from JavaScript without recompiling anything.

Where should you define color variables?

For app-wide tokens, define them on :root. :root gives you one global source of truth, and because unregistered custom properties inherit by default, child elements can consume them automatically.

:root {
  --color-gray-50: #fafafa;
  --color-gray-100: #f4f4f5;
  --color-gray-900: #18181b;
  --color-cyan-500: #06b6d4;
  --color-cyan-700: #0e7490;
}

But that is only the first layer. The pattern that scales better is:

  1. Primitive palette tokens
  2. Semantic tokens
  3. Component tokens only when needed

Primitive tokens are raw colors. Semantic tokens describe intent.

:root {
  --cyan-500: #06b6d4;
  --cyan-700: #0e7490;
  --zinc-50: #fafafa;
  --zinc-900: #18181b;

  --color-bg: var(--zinc-900);
  --color-text: var(--zinc-50);
  --color-link: var(--cyan-500);
  --color-button-bg: var(--cyan-700);
}

That extra layer looks annoying on day one. It saves you later. If marketing decides the primary accent should shift from cyan to blue, you change the semantic mapping, not every component rule. The same idea came up in our Color Formats Explained post: raw color values are useful, but naming them by intent is what keeps a system maintainable.

Why are semantic color tokens better than raw hex values?

Semantic tokens are better because they describe the job a color is doing instead of the pigment itself. --color-danger is more stable than --red-600 when the design team changes the brand palette or adjusts contrast.

Here is the difference:

/* brittle */
.alert {
  background: #fef2f2;
  border-color: #dc2626;
  color: #7f1d1d;
}

/* maintainable */
:root {
  --color-danger-bg: #fef2f2;
  --color-danger-border: #dc2626;
  --color-danger-text: #7f1d1d;
}

.alert {
  background: var(--color-danger-bg);
  border-color: var(--color-danger-border);
  color: var(--color-danger-text);
}

This also makes accessibility work less chaotic. If you improve contrast for --color-muted-text, every place using that token gets the fix. You are editing a system, not patching scattered declarations.

How should you handle dark mode with CSS color variables?

Dark mode is where CSS variables earn their keep. Instead of duplicating component rules, keep the component styles fixed and swap the tokens they reference.

The clean baseline is prefers-color-scheme:

:root {
  --color-bg: #ffffff;
  --color-surface: #f4f4f5;
  --color-text: #18181b;
  --color-border: #d4d4d8;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #09090b;
    --color-surface: #18181b;
    --color-text: #f4f4f5;
    --color-border: #3f3f46;
  }
}

Then your components stay boring, which is exactly what you want:

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

If you want a manual theme toggle, use an attribute or class override:

[data-theme="dark"] {
  --color-bg: #09090b;
  --color-surface: #18181b;
  --color-text: #f4f4f5;
  --color-border: #3f3f46;
}

That approach usually beats maintaining separate .dark .button, .dark .card, .dark .modal rules forever. The components just read tokens. The theme owns the values.

How do fallback values work in var()?

var() lets you provide a fallback value if a custom property is missing or invalid. The syntax is var(--token, fallback).

.badge {
  background: var(--color-badge-bg, #e4e4e7);
  color: var(--color-badge-text, #18181b);
}

Fallbacks matter when you ship reusable components, embed widgets on third-party sites, or progressively roll out a design-token system. They are also useful when you override only part of a theme and want sensible defaults for the rest.

You can nest them too:

color: var(--color-link, var(--color-brand, #0ea5e9));

Two common mistakes:

  1. Assuming fallbacks protect you from every invalid declaration. They only kick in when the custom property is missing or invalid at substitution time.
  2. Forgetting that everything after the first comma belongs to the fallback. That matters for functions like rgb() and color-mix().

Can you update CSS color variables with JavaScript?

Yes, and this is one of the best reasons to use them. Because the variables remain in the browser, you can change them with CSSOM and let the cascade do the rest.

const root = document.documentElement;

root.style.setProperty('--color-brand', '#2563eb');
root.style.setProperty('--color-button-bg', '#1d4ed8');

This is useful for:

  1. Theme pickers
  2. User personalization
  3. White-label apps
  4. Seasonal campaigns
  5. Live previews in admin interfaces

If you need to inspect the current resolved value, use getComputedStyle:

const currentBrand = getComputedStyle(document.documentElement)
  .getPropertyValue('--color-brand')
  .trim();

That runtime flexibility is the real gap between CSS variables and preprocessor variables. If a user picks a new accent color in settings, CSS variables can apply it immediately. Sass cannot, because the values were baked in before the page shipped.

Which color format should you store in CSS variables?

There is no single winner. The right format depends on how much manipulation you need.

HEX is compact and familiar. RGB is handy when you need explicit channel values. HSL is easier to reason about when you want predictable hue, saturation, and lightness adjustments. If you are building palettes from scratch, HSL is often more comfortable than raw hex because you can think in terms of "same hue, darker lightness" instead of hunting for the right six-character code. The Color Converter is useful here because you can move between the formats without guessing.

For example, a simple HSL token scale reads cleanly:

:root {
  --brand-100: hsl(221 83% 90%);
  --brand-300: hsl(221 83% 75%);
  --brand-500: hsl(221 83% 53%);
  --brand-700: hsl(221 83% 40%);
}

That said, do not obsess over a universal format rule. Consistency inside the system matters more than ideological purity.

What are the biggest pitfalls with CSS color variables?

CSS color variables are simple until a team starts improvising. These are the problems that show up most often:

Using raw palette names everywhere. If every component depends on --blue-500, rebranding becomes tedious. Prefer semantic tokens for component-facing APIs.

Mixing unrelated responsibilities into one token. --primary is vague. --color-button-bg-primary may be too specific. Usually --color-accent or --color-text-muted is the better middle ground.

Ignoring contrast. A token system can spread a bad decision very efficiently. Check text, hover, disabled, and focus states against real contrast targets.

Creating token cycles. Declarations like --a: var(--b) and --b: var(--a) become invalid at computed-value time.

Expecting variables to work everywhere. You can use them in property values, but not in selectors, media query conditions, or class names.

Skipping defaults in shared components. If a component may be used outside your full app shell, give important tokens a fallback.

If you need tighter guarantees, modern browsers also support registering custom properties with @property. That matters more for typed animation and validation than for everyday color tokens, but it is worth knowing the feature exists.

Quick reference

Need Best pattern
Global app colors Define tokens on :root
Stable design system names Map primitive palette tokens to semantic tokens
Dark mode Override token values, not component rules
Reusable component safety Use var(--token, fallback)
Live theme changes Update tokens with element.style.setProperty()
Easier shade tweaking Store palettes in HSL when useful
Better maintainability Avoid hardcoded hex values inside components

CSS color variables are not just a cleaner way to write colors. They are the foundation for theming, dark mode, and design tokens that do not collapse under change. Start with a small semantic token layer, keep component rules dumb, and let the cascade do the heavy lifting.

Try the tool mentioned in this article:

Open Tool →