CSS custom properties — commonly called CSS variables — are one of the most impactful features added to CSS in the last decade. They bring real variable behavior to stylesheets: declare a value once, use it everywhere, and change everything from one place. They are the foundation of modern design tokens, dark mode implementation, component theming, and runtime UI customization.

This guide covers everything from the basic syntax to advanced patterns: scope and inheritance, fallback values, manipulating variables with JavaScript, building a complete dark/light mode system, and integrating variables into a design token architecture. Also see our CSS custom properties deep dive for browser compatibility and the @property API.

Syntax: Defining and Using CSS Variables

Custom properties start with two dashes (--) and can hold any CSS value — a color, a size, a string, even a full value fragment. The var() function retrieves them:

/* Define on :root for global scope */
:root {
  --color-primary: #7c6fff;
  --color-text: #f0f0ff;
  --spacing-base: 16px;
  --radius: 8px;
  --shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

/* Use anywhere */
.button {
  background: var(--color-primary);
  color: var(--color-text);
  padding: var(--spacing-base);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

Fallback Values

The var() function accepts a second argument as a fallback for when the variable is not defined:

.component {
  /* If --brand-color is not defined, use #7c6fff */
  color: var(--brand-color, #7c6fff);

  /* Nested fallback — use --secondary if --primary is not set, else red */
  color: var(--primary, var(--secondary, red));
}

Scope and Inheritance

CSS custom properties follow the normal CSS cascade and inheritance rules. A variable defined on a parent element is inherited by all its children. You can redefine a variable at a lower scope to override it for a subtree — this is what makes component-level theming possible:

/* Global defaults */
:root {
  --accent: #7c6fff;
  --bg-card: #13131a;
}

/* Override for a specific component */
.card-danger {
  --accent: #ef4444;
  --bg-card: #1f0f0f;
}

/* This button uses --accent — it'll be red inside .card-danger */
.card-danger .button {
  background: var(--accent);
}

This scoping mechanism is more powerful than SCSS variables, which are resolved at compile time and can't change based on DOM context.

Dark Mode with CSS Variables

CSS custom properties are the cleanest way to implement dark/light mode. Define your semantic color tokens on :root, then redefine them inside a media query or a class:

/* Light mode defaults (or dark mode — pick one as your base) */
:root {
  --bg: #f5f5f8;
  --surface: #ffffff;
  --text: #1a1a2e;
  --muted: #6a6a8a;
  --accent: #6c5ce7;
  --border: #d8d8e4;
}

/* Dark mode via media query (OS preference) */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0a0a0f;
    --surface: #13131a;
    --text: #f0f0ff;
    --muted: #7070a0;
    --accent: #7c6fff;
    --border: #2a2a3d;
  }
}

/* Dark mode via class (user toggle) */
:root.dark {
  --bg: #0a0a0f;
  --surface: #13131a;
  --text: #f0f0ff;
  --muted: #7070a0;
  --accent: #7c6fff;
  --border: #2a2a3d;
}

Using the class approach (alongside the media query) lets you give users a manual toggle that overrides their OS preference — exactly what CSSTools.io does with the theme button in the sidebar.

💡

Naming convention: Use semantic names like --color-text, --color-surface, and --color-accent rather than raw color names like --purple or --dark-gray. Semantic names describe intent — they remain meaningful when the palette changes, and they work for both dark and light modes.

Design Tokens Architecture

Design tokens are named values that store design decisions — colors, spacing, radii, shadows, motion durations. CSS custom properties are the ideal runtime representation of tokens. The recommended structure uses two layers:

Layer 1: Primitive Tokens

Raw values with no semantic meaning — the full color palette, the spacing scale, the full type scale:

:root {
  /* Color primitives */
  --purple-100: #ede9ff;
  --purple-400: #7c6fff;
  --purple-700: #4d3fc0;

  /* Spacing scale */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
}

Layer 2: Semantic Tokens

Tokens that reference primitives and carry semantic intent. These are what your components actually use:

:root {
  --color-bg: var(--gray-50);
  --color-surface: var(--gray-0);
  --color-text: var(--gray-900);
  --color-text-muted: var(--gray-500);
  --color-accent: var(--purple-400);
  --color-border: var(--gray-200);

  --component-radius: var(--radius-md);
  --component-padding-x: var(--space-4);
  --component-padding-y: var(--space-3);
}

/* Theme override just changes semantic tokens, not primitives */
:root.dark {
  --color-bg: var(--gray-950);
  --color-surface: var(--gray-900);
  --color-text: var(--gray-50);
  --color-text-muted: var(--gray-400);
  --color-border: var(--gray-800);
}

Generate a color palette for your design tokens

Create shades, tints, and harmonies from any base color — copy as CSS variables instantly.

Open Color Palette Generator →

Using CSS Variables with JavaScript

One of the most powerful aspects of CSS custom properties is that JavaScript can read and write them at runtime, enabling dynamic theming that would be impossible with SCSS variables (which are compiled away):

Reading a CSS Variable

const root = document.documentElement;
const accent = getComputedStyle(root).getPropertyValue('--accent').trim();
console.log(accent); // "#7c6fff"

Setting a CSS Variable

// Change the accent color for the whole page
document.documentElement.style.setProperty('--accent', '#22c55e');

// Change a variable on a specific element
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#1a1a2e');
card.style.setProperty('--card-radius', '20px');

Theme Switcher Pattern

function setTheme(theme) {
  const root = document.documentElement;

  if (theme === 'dark') {
    root.classList.add('dark');
    root.classList.remove('light');
  } else {
    root.classList.add('light');
    root.classList.remove('dark');
  }

  localStorage.setItem('theme', theme);
}

// Restore on page load (before paint to avoid flash)
const saved = localStorage.getItem('theme') || 'light';
setTheme(saved);

CSS Variables for Component Theming

CSS custom properties make component APIs possible in pure CSS. A button component can expose its own custom properties that consumers override — just like React props, but in CSS:

/* Button defaults — defined within the component */
.btn {
  --btn-bg: var(--accent);
  --btn-color: #fff;
  --btn-radius: 8px;
  --btn-padding: 10px 20px;

  background: var(--btn-bg);
  color: var(--btn-color);
  border-radius: var(--btn-radius);
  padding: var(--btn-padding);
  border: none;
  cursor: pointer;
  font-family: inherit;
}

/* Consumer overrides — no specificity battles */
.btn-outline {
  --btn-bg: transparent;
  --btn-color: var(--accent);
  border: 2px solid var(--accent);
}

.btn-danger {
  --btn-bg: #ef4444;
}

.btn-large {
  --btn-padding: 14px 28px;
  --btn-radius: 12px;
}

CSS Variables vs SCSS Variables

SCSS variables ($variable) are resolved at compile time and produce static CSS. CSS custom properties exist at runtime and can change. The practical differences:

Frequently Asked Questions

Are CSS variables the same as SCSS variables?
No. SCSS variables ($var) are resolved at compile time and disappear from the CSS output. CSS custom properties (--var) are real browser features that exist at runtime, can be changed by JavaScript, cascade through the DOM, and are visible in DevTools. SCSS is a build tool; CSS custom properties are a browser standard.
Can CSS variables be used inside media queries?
You cannot define a CSS variable inside a media query and have it affect the variable itself — media queries can't change which variables exist. But you can redefine variable values inside a media query on a selector, which effectively gives you responsive tokens: @media (max-width: 768px) { :root { --font-size-base: 14px; } }
Do CSS variables work in all browsers?
CSS custom properties are supported in all modern browsers since 2016 (Chrome 49+, Firefox 31+, Safari 9.1+). IE11 does not support them. If you still need IE11 support, use a PostCSS plugin to inline values at build time, or provide static fallbacks using the cascade (the browser ignores the var() and uses the static value above it).
Can I use CSS variables inside calc()?
Yes — this is one of the most useful combinations. calc(var(--spacing-base) * 2), calc(100% - var(--sidebar-width)), calc(var(--font-size) * var(--line-height)) — all valid. The variable must resolve to a number or length for the math to work.
What happens if a CSS variable is invalid or undefined?
If a variable is used but not defined, and there's no fallback, the property value becomes the initial value (e.g. color becomes black, background becomes transparent). If the variable is defined but its value is invalid for the property (e.g. a color variable being used as a font-size), the property is treated as invalid and falls back to the inherited value or initial value.

Ready to put these techniques into practice? Use our Color Palette Generator to create a palette you can paste directly as CSS variables, or read the CSS custom properties deep dive for the @property typed custom properties API.