CSS Custom Properties (Variables) Explained with Real Examples

CSS Custom Properties (Variables) Explained with Real Examples

CSS custom properties — commonly called CSS variables — have been in browsers since 2016, but most developers still use them only as a find-and-replace mechanism for repeated values. That's useful, but it barely scratches the surface. Custom properties are live, they inherit down the DOM, and they're readable and writable from JavaScript. That changes what's possible.

The Syntax

A custom property is any property whose name starts with --. You define it, and then reference it with var():

:root {
  --color-accent: #f5c842;
  --spacing-md: 1rem;
  --border-radius: 8px;
}

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

:root is the conventional home for global custom properties — it's the highest-level element in the DOM, so anything defined there is accessible everywhere below it.

The name can be anything after --. Convention is --category-name (kebab-case), like --color-primary, --font-body, --spacing-lg.

How They Differ from Preprocessor Variables

If you've used Sass or Less, CSS custom properties look familiar but behave very differently. Sass variables are compile-time replacements — they're resolved before the browser sees the CSS. Custom properties are runtime values that live in the browser:

/* Sass — static, gone after compile */
$accent: #f5c842;
.button { background: $accent; } /* becomes background: #f5c842 */
/* CSS custom properties — live in the browser */
:root { --accent: #f5c842; }
.button { background: var(--accent); } /* stays as var(--accent) at runtime */

Because they're live, you can change them with JavaScript. They participate in the cascade. They inherit. They respond to media queries. None of that is possible with Sass variables.

The Cascade and Inheritance

Custom properties follow the same cascade rules as any other CSS property. A custom property defined on a child element overrides the inherited value from a parent:

:root { --text-color: #e4e4e4; }

.card {
  --text-color: #ffffff; /* scoped override */
  color: var(--text-color);
}

.card p {
  color: var(--text-color); /* inherits from .card, gets #ffffff */
}

This scoping behavior is the key to component-level theming. You can define a --surface-bg and --text-color for a .dark-card variant and every child inside it picks up those values automatically — no BEM modifiers on every child element needed.

Fallback Values

var() accepts a second argument as a fallback:

.widget {
  color: var(--widget-text, var(--text-color, #e4e4e4));
}

The fallback can itself be another var(). The browser works through the chain until it finds a defined value. This is useful for component libraries where a consuming project might or might not define the expected custom property.

Using Custom Properties in `calc()`

Custom properties integrate naturally with calc():

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

.stack > * + * {
  margin-top: calc(var(--spacing-unit) * 4); /* 1rem */
}

.stack-lg > * + * {
  margin-top: calc(var(--spacing-unit) * 8); /* 2rem */
}

A single --spacing-unit acts as a multiplier base. Change one number and your entire spacing scale shifts proportionally. This is much more maintainable than a set of disconnected spacing values.

Changing Custom Properties with JavaScript

This is where custom properties become genuinely powerful. Reading and writing them from JS is straightforward:

// Read a custom property
const root = document.documentElement;
const accent = getComputedStyle(root).getPropertyValue('--color-accent').trim();

// Write a custom property
root.style.setProperty('--color-accent', '#60a5fa');

// Scope it to a specific element
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#1f1f1f');

setProperty triggers a full cascade recalculation, so every element using that variable updates immediately. No class toggling, no style recalculation loops.

Building a Theme System

Dark mode via custom properties is one of the cleanest patterns available:

:root {
  --bg: #ffffff;
  --bg2: #f5f5f5;
  --text: #111111;
  --text2: #4a4a4a;
  --border: #e0e0e0;
}

[data-theme="dark"] {
  --bg: #0d0d0d;
  --bg2: #161616;
  --text: #e4e4e4;
  --text2: #a8a8a8;
  --border: #252525;
}
function toggleTheme() {
  const root = document.documentElement;
  const current = root.getAttribute('data-theme');
  root.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
}

A single attribute change on <html> flips every color on the page. No JavaScript class juggling across multiple elements, no re-rendering. The cascade handles propagation.

You can also use the prefers-color-scheme media query to set defaults:

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0d0d0d;
    --text: #e4e4e4;
  }
}

The JS toggle overrides the media query because [data-theme="dark"] is declared later in the stylesheet than the @media (prefers-color-scheme: dark) block. Both selectors have equal specificity (0,0,1,0), so source order is the tiebreaker.

Building a Spacing Scale

A token-based spacing system makes component sizing consistent without magic numbers scattered everywhere:

:root {
  --space-1: 0.25rem;  /*  4px */
  --space-2: 0.5rem;   /*  8px */
  --space-3: 0.75rem;  /* 12px */
  --space-4: 1rem;     /* 16px */
  --space-6: 1.5rem;   /* 24px */
  --space-8: 2rem;     /* 32px */
  --space-12: 3rem;    /* 48px */
  --space-16: 4rem;    /* 64px */
}

You reference these tokens throughout your components. When your designer decides the base unit should be 18px instead of 16px, you change one value. Everything scales.

`@property` for Registered Custom Properties

@property lets you register a custom property with an explicit type, initial value, and inheritance rule:

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

.spinner {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to { --rotation: 360deg; }
}

.spinner {
  transform: rotate(var(--rotation));
}

Registered properties unlock CSS transitions and animations on custom properties — something unregistered custom properties can't do. The browser needs to know the type to interpolate between values. Without @property, you can't animate var(--rotation) from 0deg to 360deg because the browser treats it as an opaque string.

@property is also useful for guarding against invalid values — the syntax field type-checks incoming values.

Color Systems with Custom Properties

A layered color system keeps your design tokens organized and flexible:

:root {
  /* Primitive palette */
  --yellow-400: #f5c842;
  --yellow-500: #d4a82a;
  --blue-400: #60a5fa;
  --red-400: #f87171;
  --green-400: #4ade80;

  /* Semantic tokens — reference primitives */
  --color-accent: var(--yellow-400);
  --color-accent-hover: var(--yellow-500);
  --color-error: var(--red-400);
  --color-success: var(--green-400);
  --color-link: var(--blue-400);
}

Separating primitive colors from semantic tokens means you can rebrand by updating the semantic layer without touching the primitives — and the primitives stay available for one-off use.

If you're converting color values between formats (hex to HSL, RGB to oklch) while building a color system, Color Converter handles that in the browser. And once your CSS is ready to ship, CSS Minifier strips the whitespace and comments without touching the variable names.

For the cascade mechanics behind how custom property scoping works, CSS Specificity: The Complete Guide explains why a child-element override beats the :root declaration. And for how these variables interact inside component layouts, Flexbox vs Grid shows practical patterns where custom properties make spacing and sizing systems click together.