CSS custom properties — often called CSS variables — are one of the most practical additions to CSS in the last decade. Unlike preprocessor variables from Sass or Less, custom properties are live in the browser. They cascade, they inherit, they can be changed at runtime with JavaScript, and they respond to media queries. This makes them fundamentally different from compile-time variables, and far more powerful for dynamic styling.

This guide covers the syntax, scoping rules, fallback patterns, theming strategies, and JavaScript integration you need to use custom properties effectively.

CSS Custom Properties Syntax Basics

Custom properties are declared with a double-dash prefix and accessed with the var() function:

:root {
  --primary-color: #3b82f6;
  --spacing-md: 1rem;
  --font-stack: 'Inter', system-ui, sans-serif;
}

.button {
  background-color: var(--primary-color);
  padding: var(--spacing-md);
  font-family: var(--font-stack);
}

The :root pseudo-class is the conventional place to define global custom properties. It matches the <html> element with higher specificity than a plain html selector.

Naming Conventions

There is no enforced naming convention, but consistency matters. Common patterns include:

  • Flat naming: --color-primary, --font-size-lg
  • Component-scoped: --button-bg, --card-padding
  • Token-based: --color-blue-500, --space-4

Pick a pattern and stick with it across your project.

Scoping and the Cascade

This is where custom properties differ from preprocessor variables. They follow the CSS cascade, meaning you can redefine them at any level of specificity and the change applies to that element and its descendants.

:root {
  --text-color: #1f2937;
}

.dark-section {
  --text-color: #f3f4f6;
}

p {
  color: var(--text-color);
}

A <p> inside .dark-section gets light text. A <p> outside it gets dark text. Same property, same rule, different values based on context.

This cascading behavior is incredibly useful for component theming:

.card {
  --card-bg: white;
  --card-border: #e5e7eb;

  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 1.5rem;
}

.card.featured {
  --card-bg: #eff6ff;
  --card-border: #3b82f6;
}

The .featured variant only changes the custom properties. The rest of the styling stays in one place, which reduces duplication and makes maintenance easier.

Fallback Values

The var() function accepts a second argument as a fallback value:

.element {
  color: var(--accent-color, #3b82f6);
  padding: var(--custom-padding, 1rem);
}

If --accent-color is not defined in the current scope, the element uses #3b82f6. Fallbacks can also reference other custom properties:

.element {
  color: var(--accent-color, var(--primary-color, blue));
}

This chain checks --accent-color first, falls back to --primary-color, and finally falls back to blue.

The Invalid Value Trap

Be aware of how custom properties handle invalid values. If a custom property resolves to a value that is invalid for the property where it is used, the property is set to its inherited value (or initial value if it does not inherit) — not the fallback.

:root {
  --size: red; /* a color, not a valid length */
}

.box {
  width: var(--size); /* invalid for width — resets to auto, not the fallback */
}

This catches people off guard. The fallback in var() is only used when the custom property is not defined at all, not when its value is the wrong type.

Theming with Custom Properties

Custom properties are the standard approach for theme switching in modern CSS. Here is a dark mode implementation:

:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f9fafb;
  --text-primary: #111827;
  --text-secondary: #6b7280;
  --border-color: #e5e7eb;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] {
  --bg-primary: #111827;
  --bg-secondary: #1f2937;
  --text-primary: #f9fafb;
  --text-secondary: #9ca3af;
  --border-color: #374151;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

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

.card {
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  box-shadow: var(--shadow);
}

Toggle the theme by setting data-theme="dark" on any ancestor element. Every descendant that uses those custom properties updates automatically.

You can also combine this with the prefers-color-scheme media query for automatic OS-level theme detection:

@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: #111827;
    --text-primary: #f9fafb;
    /* ... other dark values */
  }
}

For testing color values and converting between formats as you build your theme, try the color converter. When building out a full design system, the Tailwind CSS playground can help you experiment with utility-driven theming approaches. For a deeper walkthrough of light/dark theme switching itself, see the guide to implementing dark mode.

Using Custom Properties with Calc

Custom properties work seamlessly with calc(), enabling dynamic sizing systems:

:root {
  --base-size: 1rem;
  --scale-ratio: 1.25;
}

h1 { font-size: calc(var(--base-size) * var(--scale-ratio) * var(--scale-ratio) * var(--scale-ratio)); }
h2 { font-size: calc(var(--base-size) * var(--scale-ratio) * var(--scale-ratio)); }
h3 { font-size: calc(var(--base-size) * var(--scale-ratio)); }
body { font-size: var(--base-size); }

You can also create spacing scales:

:root {
  --space-unit: 0.25rem;
}

.mt-1 { margin-top: var(--space-unit); }
.mt-2 { margin-top: calc(var(--space-unit) * 2); }
.mt-4 { margin-top: calc(var(--space-unit) * 4); }
.mt-8 { margin-top: calc(var(--space-unit) * 8); }

JavaScript Interaction

One of the biggest advantages of custom properties over preprocessor variables is that you can read and write them from JavaScript at runtime.

Reading Custom Properties

const element = document.querySelector('.card');
const styles = getComputedStyle(element);
const bgColor = styles.getPropertyValue('--card-bg').trim();
console.log(bgColor); // "white" or "#eff6ff"

Setting Custom Properties

// Set on a specific element
document.querySelector('.card').style.setProperty('--card-bg', '#fef3c7');

// Set globally
document.documentElement.style.setProperty('--primary-color', '#ef4444');

Practical Example: Dynamic Theming

const themeToggle = document.getElementById('theme-toggle');

themeToggle.addEventListener('click', () => {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
});

// Restore saved theme
const saved = localStorage.getItem('theme');
if (saved) {
  document.documentElement.setAttribute('data-theme', saved);
}

Reactive Styling

You can create interactive effects by binding custom properties to user input:

document.addEventListener('mousemove', (e) => {
  const x = e.clientX / window.innerWidth;
  const y = e.clientY / window.innerHeight;
  document.documentElement.style.setProperty('--mouse-x', x);
  document.documentElement.style.setProperty('--mouse-y', y);
});
.spotlight {
  background: radial-gradient(
    circle at calc(var(--mouse-x) * 100%) calc(var(--mouse-y) * 100%),
    rgba(59, 130, 246, 0.3),
    transparent 50%
  );
}

Custom Properties in Animations

Custom properties can be animated using @property registration, which tells the browser the type of value:

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

.animated-gradient {
  background: linear-gradient(var(--gradient-angle), #3b82f6, #8b5cf6);
  animation: rotate-gradient 3s linear infinite;
}

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

Without @property, the browser cannot interpolate between values because it treats custom properties as strings. Registering the property with a syntax descriptor enables smooth animation.

Best Practices

  1. Define design tokens at :root for global values like colors, spacing, and typography. Use component-level custom properties for scoped overrides.

  2. Always provide fallbacks for custom properties that might not be defined. This makes components more portable.

  3. Do not overuse custom properties. A value used only once in one place does not need to be a variable. Extract shared values, not every value.

  4. Use semantic names over raw values. --color-danger is better than --color-red-500 because it communicates intent, not implementation.

  5. Keep the scope minimal. Define custom properties as close to their usage as practical. Global variables are convenient but make it harder to track what affects what.

Summary

CSS custom properties bring true runtime variables to your stylesheets. They cascade through the DOM tree, respond to selectors and media queries, and can be manipulated with JavaScript — none of which preprocessor variables can do. Use them for theming, dynamic layouts, component APIs, and anywhere you need values that change based on context. They are supported in every modern browser and are ready for production use today.