Tutorials

CSS Custom Properties for Dynamic Color Systems

7 min read

CSS custom properties — commonly called CSS variables — are the foundation of any modern, maintainable color system. They let you define a color once and reference it everywhere, making global theme changes a one-line edit instead of a find-and-replace across hundreds of files. Combined with the cascade and JavaScript, they unlock dynamic theming capabilities that preprocessors like Sass or Less simply cannot match.

This tutorial walks through building a production-ready color system from scratch using CSS custom properties: from the basics through light/dark mode switching, component-level overrides, and runtime color manipulation with JavaScript.

CSS Variables Basics

A CSS custom property is any property whose name begins with two dashes (--). You declare it like any other property and read it with the var() function.

:root {
  --color-brand: #2563EB;
}

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

The :root selector is equivalent to the html element but with higher specificity, making it the conventional place for global tokens. Any element in the document can read --color-brand.

Fallback Values

The var() function accepts an optional fallback, used when the variable is undefined or invalid:

.card {
  /* Falls back to #6B7280 if --color-secondary is not defined */
  color: var(--color-secondary, #6B7280);
}

Fallbacks can be nested — the second argument to var() is itself allowed to use var() — which is useful for layered design token systems.

Custom Properties vs. Preprocessor Variables

Sass and Less variables are resolved at compile time and baked into the generated CSS. Once compiled, they are gone. CSS custom properties live in the browser and respond to the cascade, inheritance, and JavaScript. This distinction enables everything that follows in this tutorial.

Color Theme Architecture

A well-designed token system has at least two layers: primitive tokens (raw values) and semantic tokens (purpose-driven references).

Primitive Tokens: The Full Palette

Primitive tokens define every color your system can use. The best practice is to generate a complete shade scale for each hue you need. Use the Shade Generator to produce a Tailwind-style 50–950 scale for any base color.

For example, starting from a brand blue #2563EB:

:root {
  /* Blue scale — generated from #2563EB */
  --blue-50:  #EFF6FF;
  --blue-100: #DBEAFE;
  --blue-200: #BFDBFE;
  --blue-300: #93C5FD;
  --blue-400: #60A5FA;
  --blue-500: #3B82F6;
  --blue-600: #2563EB;
  --blue-700: #1D4ED8;
  --blue-800: #1E40AF;
  --blue-900: #1E3A8A;
  --blue-950: #172554;

  /* Neutral scale */
  --gray-50:  #F9FAFB;
  --gray-100: #F3F4F6;
  --gray-200: #E5E7EB;
  --gray-300: #D1D5DB;
  --gray-400: #9CA3AF;
  --gray-500: #6B7280;
  --gray-600: #4B5563;
  --gray-700: #374151;
  --gray-800: #1F2937;
  --gray-900: #111827;
  --gray-950: #030712;
}

Primitive tokens are never used directly in component styles. They exist solely to be referenced by semantic tokens.

Semantic Tokens: Intent Over Value

Semantic tokens give meaning to the raw values. Instead of --blue-600, your component uses --color-action-primary. This abstraction allows you to later reassign --color-action-primary to a green or purple and have every component that uses it update automatically.

:root {
  /* Background */
  --color-bg-base:       var(--gray-50);
  --color-bg-surface:    #FFFFFF;
  --color-bg-elevated:   #FFFFFF;
  --color-bg-subtle:     var(--gray-100);

  /* Text */
  --color-text-primary:  var(--gray-900);
  --color-text-secondary: var(--gray-600);
  --color-text-disabled: var(--gray-400);
  --color-text-inverse:  #FFFFFF;

  /* Brand / Action */
  --color-action-primary:       var(--blue-600);
  --color-action-primary-hover: var(--blue-700);
  --color-action-primary-text:  #FFFFFF;

  /* Borders */
  --color-border-base:   var(--gray-200);
  --color-border-strong: var(--gray-400);

  /* Feedback */
  --color-success: #16A34A;
  --color-warning: #D97706;
  --color-error:   #DC2626;
  --color-info:    var(--blue-600);
}

Component styles then reference semantic tokens exclusively:

.button-primary {
  background-color: var(--color-action-primary);
  color:            var(--color-action-primary-text);
  border:           1px solid var(--color-action-primary);
}

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

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

This two-layer architecture keeps the system flexible and refactorable. If your brand color changes, you update one primitive token and the semantic layer propagates the change everywhere.

Light/Dark Toggle Pattern

CSS custom properties inherit through the document tree, which means you can redefine a token at a specific scope and every descendant inherits the new value. This is the mechanism that makes light/dark theming trivial.

Approach 1: prefers-color-scheme (Automatic)

The simplest implementation responds to the user's operating system preference:

:root {
  /* Light mode (default) */
  --color-bg-base:      #FFFFFF;
  --color-bg-surface:   #F9FAFB;
  --color-text-primary: #111827;
  --color-text-secondary: #6B7280;
  --color-border-base:  #E5E7EB;
  --color-action-primary: #2563EB;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Dark mode overrides */
    --color-bg-base:      #111827;
    --color-bg-surface:   #1F2937;
    --color-text-primary: #F9FAFB;
    --color-text-secondary: #9CA3AF;
    --color-border-base:  #374151;
    --color-action-primary: #3B82F6;
  }
}

Every component automatically gets the right colors. No JavaScript required. The component CSS does not change at all.

Approach 2: Data Attribute Toggle (Manual)

For user-controlled theme switching, the common pattern is a data-theme attribute on the html or body element:

:root,
[data-theme="light"] {
  --color-bg-base:      #FFFFFF;
  --color-text-primary: #111827;
  --color-action-primary: #2563EB;
}

[data-theme="dark"] {
  --color-bg-base:      #111827;
  --color-text-primary: #F9FAFB;
  --color-action-primary: #60A5FA;
}

Notice that the dark mode uses #60A5FA (blue-400) instead of #2563EB (blue-600) for the primary action color. On dark backgrounds, lighter shades of blue maintain accessible contrast. Use the Contrast Checker to verify that every semantic token pair (text on background) passes WCAG AA in both modes.

Toggle the theme with a small JavaScript function:

function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('color-theme', theme);
}

// On page load, respect saved preference
const saved = localStorage.getItem('color-theme');
if (saved) {
  setTheme(saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
  setTheme('dark');
}

// Toggle button
document.getElementById('theme-toggle').addEventListener('click', () => {
  const current = document.documentElement.getAttribute('data-theme');
  setTheme(current === 'dark' ? 'light' : 'dark');
});

Preventing Flash of Unstyled Content

When using localStorage for theme persistence, place a blocking inline script in the <head> — before any stylesheets — to apply the saved theme attribute before the browser paints:

<head>
  <script>
    const theme = localStorage.getItem('color-theme') ||
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
  </script>
  <link rel="stylesheet" href="styles.css">
</head>

This small inline script ensures the correct theme is applied before any CSS is parsed, eliminating the white-flash-then-dark transition that plagues poorly implemented dark modes.

Component-Level Color Overrides

One of the most powerful features of CSS custom properties is scoped overrides — redefining a token within a specific component's scope so that its children inherit the local value.

Use Case: Inverted (Dark) Hero Section

Suppose you have a global semantic token --color-bg-base set to white, and you want a dark hero section where all text automatically adapts:

.hero--dark {
  --color-bg-base:        #111827;
  --color-text-primary:   #F9FAFB;
  --color-text-secondary: #9CA3AF;
  --color-border-base:    #374151;

  background-color: var(--color-bg-base);
  color: var(--color-text-primary);
}

/* No changes needed — .hero__title inherits the overridden values */
.hero__title {
  color: var(--color-text-primary);
}

.hero__subtitle {
  color: var(--color-text-secondary);
}

The .hero--dark modifier redefines the tokens locally. Every element inside .hero--dark that uses those tokens inherits the dark values automatically, without any class-name duplication like hero__title--dark.

Use Case: Branded Alert Variants

Component-level overrides are ideal for semantic variants:

.alert {
  background-color: var(--alert-bg, var(--color-bg-subtle));
  color:            var(--alert-text, var(--color-text-primary));
  border-left:      4px solid var(--alert-accent, var(--color-border-strong));
  padding:          1rem 1.25rem;
  border-radius:    0.375rem;
}

.alert--success {
  --alert-bg:     #F0FDF4;
  --alert-text:   #14532D;
  --alert-accent: #16A34A;
}

.alert--error {
  --alert-bg:     #FEF2F2;
  --alert-text:   #7F1D1D;
  --alert-accent: #DC2626;
}

.alert--warning {
  --alert-bg:     #FFFBEB;
  --alert-text:   #78350F;
  --alert-accent: #D97706;
}

The .alert base component references private component-level variables (--alert-bg, --alert-text, --alert-accent) with fallbacks to global tokens. The variant modifiers set those private variables. This pattern keeps the base component's CSS clean and makes adding new variants trivial.

Runtime Color Changes with JavaScript

Because CSS custom properties are live in the browser, JavaScript can read and write them at any time — enabling dynamic color systems that respond to user input, data, or application state.

Reading and Writing Custom Properties

const root = document.documentElement;

// Read a custom property
const brandColor = getComputedStyle(root).getPropertyValue('--color-action-primary').trim();
console.log(brandColor); // '#2563EB'

// Set a custom property
root.style.setProperty('--color-action-primary', '#7C3AED');

// Remove an override (reverts to stylesheet value)
root.style.removeProperty('--color-action-primary');

Use Case: User-Customizable Brand Color

Suppose you are building a SaaS dashboard where each tenant can set their brand color. You receive the brand color from an API and apply it at runtime:

async function applyTenantTheme(brandHex) {
  // brandHex: '#7C3AED' (a purple)
  const root = document.documentElement;

  // Set the primary action color
  root.style.setProperty('--color-action-primary', brandHex);

  // Derive a hover variant (computed server-side or via a small library)
  const hoverHex = darkenColor(brandHex, 0.1);
  root.style.setProperty('--color-action-primary-hover', hoverHex);
}

The Color Converter can help you work out what OKLCH or HSL values correspond to any HEX code, which is useful when building shade derivation logic. For example, #7C3AED in OKLCH is approximately oklch(0.52 0.23 295) — you can derive lighter and darker variants by adjusting the L component while holding C and H constant.

Use Case: Real-Time Color Theme Picker

<input type="color" id="brand-picker" value="#2563EB">
document.getElementById('brand-picker').addEventListener('input', (e) => {
  document.documentElement.style.setProperty('--color-action-primary', e.target.value);
});

This is a genuine live theme editor in twelve lines of code. No build step, no library, no re-render cycle. The browser updates every element using --color-action-primary instantly as the user drags the color picker.

Animating Custom Properties

Custom properties can also be interpolated using @property, the Houdini API that lets you declare a property's type and initial value:

@property --gradient-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

.animated-border {
  --gradient-angle: 0deg;
  background: conic-gradient(
    from var(--gradient-angle),
    #2563EB, #7C3AED, #EC4899, #2563EB
  );
  animation: rotate 4s linear infinite;
}

@keyframes rotate {
  to { --gradient-angle: 360deg; }
}

Without @property, the browser does not know that --gradient-angle is an angle and cannot interpolate it. With it, the animation runs smoothly.

Key Takeaways

  • CSS custom properties are live values, not compile-time substitutions. The cascade, inheritance, and JavaScript all apply — giving you capabilities that no preprocessor variable system can match.
  • Use a two-layer token architecture: primitive tokens (raw color values) and semantic tokens (purpose-driven references). Components reference only semantic tokens. This makes large-scale refactoring a single-line change.
  • Light/dark mode is just a token redefinition: use @media (prefers-color-scheme: dark) for automatic switching or a data-theme attribute for manual control. Components never need variant-specific CSS.
  • Component-level overrides let you create inverted sections and semantic variants by redefining tokens at a local scope — no extra class names or duplicated CSS needed.
  • JavaScript can read and write custom properties at runtime, enabling live theme editors, tenant-specific branding, and data-driven color changes.
  • Always verify accessible contrast in both light and dark modes using the Contrast Checker. Dark-mode blues and greens often need to be lighter than their light-mode counterparts to maintain adequate contrast ratios.
  • Use the Shade Generator to produce complete, evenly-spaced color scales for your primitive token palette, and the Color Converter to translate between HEX, RGB, HSL, and OKLCH when building derivation logic.

Related Colors

Related Brands

Related Tools