Skip to content
intermediate css 12 min read

CSS Variables (Custom Properties): Complete Guide

Master CSS custom properties for maintainable, dynamic stylesheets.

css variables css custom properties var() css css variables tutorial

What Are CSS Custom Properties?

CSS custom properties — commonly called CSS variables — are user-defined values you declare once and reuse throughout your stylesheet. Unlike Sass or Less variables that get compiled away before reaching the browser, CSS custom properties exist at runtime. That distinction matters: you can read them, update them, and respond to them with JavaScript, making them a core tool for dynamic theming and interactive interfaces.

The syntax is straightforward. Declare a property with a -- prefix, then reference it with the var() function:

:root {
  --primary-color: #3498db;
  --base-font-size: 16px;
  --spacing-md: 1rem;
  --border-radius: 4px;
}

body {
  font-size: var(--base-font-size);
  color: var(--primary-color);
}

.button {
  background-color: var(--primary-color);
  padding: var(--spacing-md) calc(var(--spacing-md) * 2);
  border-radius: var(--border-radius);
  font-size: var(--base-font-size);
}

This guide walks you through everything from scoping and fallbacks to JavaScript integration and the newer @property at-rule.


Declaration and Scope

One of the most powerful features of CSS custom properties is that you can declare them on any selector, not just :root. Properties cascade down to descendants, so a variable defined on a parent applies to all its children unless overridden.

/* Global defaults */
:root {
  --bg-color: #ffffff;
  --text-color: #1a1a1a;
  --card-padding: 1.5rem;
}

/* Component-level override */
.sidebar {
  --bg-color: #f4f4f4;
  --card-padding: 1rem;
}

/* Dark section — overrides both global and component */
.dark-section {
  --bg-color: #1a1a1a;
  --text-color: #eeeeee;
}

/* These elements automatically use whatever --bg-color is in scope */
.card {
  background-color: var(--bg-color);
  color: var(--text-color);
  padding: var(--card-padding);
}

Inside .sidebar, every .card picks up the lighter background and tighter padding. Inside .dark-section, the dark values apply — no extra selectors, no specificity fights.

A few rules to remember:

  • Custom properties are case-sensitive. --primary-color and --Primary-Color are two different properties.
  • A property declared inside .sidebar is not accessible outside that element's subtree.
  • Valid names use letters, numbers, hyphens, and underscores after the -- prefix.

Fallback Values

The var() function accepts an optional second argument as a fallback value. The browser uses it when the referenced property is undefined — not as a browser compatibility shim, but as a genuine runtime safety net:

.card {
  background-color: var(--card-bg, #f9f9f9);
  border: var(--card-border, 1px solid #e0e0e0);
  color: var(--card-text, inherit);
}

/* Fallbacks can even reference other custom properties */
.hero {
  background-color: var(--hero-bg, var(--primary-color, #3498db));
}

This makes fallbacks composable. If --hero-bg isn't set, the browser tries --primary-color, and if that's also missing, it falls back to the literal hex value.

A smart pattern for design systems is separating user-facing variables from internal ones:

:root {
  /* Users can set --user-accent to override the theme */
  --accent-color: var(--user-accent, #d33a2c);
}

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

Now consumers of your component can set --user-accent on a parent element without touching your internal variable naming.


Dynamic Theming with JavaScript

This is where CSS variables genuinely outperform preprocessors. You can read and write custom properties at runtime using the CSSOM:

const root = document.documentElement;

// Read a custom property
const primaryColor = getComputedStyle(root)
  .getPropertyValue('--primary-color')
  .trim();

console.log(primaryColor); // "#3498db"

// Update a custom property globally
root.style.setProperty('--primary-color', '#e74c3c');

// Update on a specific element
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#fff3cd');

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

Here's a complete dark mode toggle that requires no class juggling beyond a single body class:

:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --surface: #f4f4f4;
  --primary: #3498db;
}

body.dark-mode {
  --bg: #121212;
  --text: #e0e0e0;
  --surface: #1e1e1e;
  --primary: #5dade2;
}

body {
  background-color: var(--bg);
  color: var(--text);
  transition: background-color 0.3s ease, color 0.3s ease;
}

.card {
  background-color: var(--surface);
  color: var(--text);
}

.button {
  background-color: var(--primary);
  color: #fff;
}
const toggle = document.querySelector('#theme-toggle');

toggle.addEventListener('click', () => {
  document.body.classList.toggle('dark-mode');

  // Persist preference
  const isDark = document.body.classList.contains('dark-mode');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
});

// Restore preference on load
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
  document.body.classList.add('dark-mode');
}

One practical note: avoid updating custom properties on high-frequency events like scroll or mousemove. Each update can trigger layout recalculations. Throttle those handlers or use CSS @keyframes where possible.


The @property At-Rule

The @property at-rule lets you formally register a custom property, specifying its type, whether it inherits, and its initial value. The main practical benefit is enabling animated transitions on custom properties — something browsers can't do with unregistered variables because they don't know how to interpolate between two arbitrary strings.

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

@property --highlight-opacity {
  syntax: "<number>";
  initial-value: 0;
  inherits: false;
}

.card {
  --gradient-angle: 45deg;
  background: linear-gradient(
    var(--gradient-angle),
    #ff6b6b,
    #4ecdc4
  );
  transition: --gradient-angle 0.6s ease;
}

.card:hover {
  --gradient-angle: 225deg;
}

Without @property, that transition would snap instantly rather than animating, because the browser treats the unregistered variable as an opaque string. With a registered <angle> type, it knows how to interpolate degrees.

You can use this same technique for animating colors, lengths, and numbers — check our gradient generator if you want to experiment with animated gradient backgrounds visually.


Practical Patterns

Responsive Component Sizing

.button {
  --btn-padding-y: 0.5rem;
  --btn-padding-x: 1rem;
  --btn-font-size: 0.875rem;

  padding: var(--btn-padding-y) var(--btn-padding-x);
  font-size: var(--btn-font-size);
  border-radius: 4px;
}

.button--large {
  --btn-padding-y: 0.75rem;
  --btn-padding-x: 1.5rem;
  --btn-font-size: 1rem;
}

@media (min-width: 768px) {
  .button--large {
    --btn-padding-y: 1rem;
    --btn-padding-x: 2rem;
    --btn-font-size: 1.125rem;
  }
}

Spacing Scale

:root {
  --space-unit: 0.25rem;
  --space-1: calc(var(--space-unit) * 1);   /* 4px */
  --space-2: calc(var(--space-unit) * 2);   /* 8px */
  --space-4: calc(var(--space-unit) * 4);   /* 16px */
  --space-8: calc(var(--space-unit) * 8);   /* 32px */
  --space-16: calc(var(--space-unit) * 16); /* 64px */
}

Change --space-unit and your entire spacing system scales. You can combine this with CSS formatter to keep generated stylesheets clean and readable.


Common Mistakes to Avoid

Replacing all Sass variables blindly. CSS custom properties earn their keep when values need to change based on DOM state — hover, media queries, JavaScript interaction. For static brand colors that never change at runtime, a preprocessor variable is perfectly fine.

Dumping everything on :root. If a variable only applies to one component, scope it to that component. Globally declared properties that are only used locally add unnecessary overhead.

Ignoring fallbacks on critical properties. If --primary-color is undefined and you have no fallback, your button has no background color. Add sensible defaults for anything that affects layout or usability.

Forgetting the .trim() when reading with JavaScript. getPropertyValue often returns a value with leading whitespace. Always call .trim() before using the result.

You can also use the color picker when prototyping your color system to quickly find and copy values in the right format for your custom properties.


Naming Conventions

Consistent naming pays dividends when your stylesheet grows. One practical convention:

:root {
  /* Tier 1: Raw values */
  --color-blue-500: #3498db;
  --color-blue-700: #2176ae;

  /* Tier 2: Semantic roles — reference raw values */
  --color-primary: var(--color-blue-500);
  --color-primary-hover: var(--color-blue-700);

  /* Tier 3: Component tokens — reference semantic roles */
  --button-bg: var(--color-primary);
  --button-bg-hover: var(--color-primary-hover);
}

When you rebrand from blue to green, you change one line in Tier 1. Everything downstream updates automatically.


Next Steps

Now that you understand CSS custom properties, these topics build naturally on what you've learned:

  • CSS calc() and math functions — Combine with custom properties for fluid typography and spacing systems using clamp() and min()/max()
  • CSS Grid and Flexbox — Use custom properties to control grid column counts and gap sizes responsively
  • Design token workflows — Export tokens from Figma or other tools and transform them directly into CSS custom properties
  • CSS animations and @keyframes — Pair with registered @property types for smooth interpolated animations

Tools to use while practicing:

  • CSS Formatter — Clean up and organize stylesheets as your variable declarations grow
  • Gradient Generator — Experiment with animated gradients using @property-registered angles and colors
  • Color Picker — Build your color token system with precise hex, HSL, and RGB values

Related Tools

Continue Learning