Code of the Day
AdvancedCSS Architecture

CSS Custom Properties

CSS custom properties (variables) inherit down the document tree and can be overridden at any scope — which makes them a native theming primitive, not just a convenience.

Web FoundationsAdvanced8 min read
Recommended first
By the end of this lesson you will be able to:
  • Declare and consume a CSS custom property correctly
  • Explain how custom properties inherit and why that makes them scope-aware
  • Override a full palette with a single attribute selector
  • Use the fallback argument to write defensive custom property declarations
  • Describe the "invalid at computed value time" failure mode and how to debug it

CSS preprocessors like Sass and Less introduced variables to stylesheets more than a decade ago. What they could not do — what a preprocessor fundamentally cannot do — is respond to the live document tree. A Sass variable is resolved at compile time, frozen into a value before the browser sees the stylesheet. A is resolved at runtime by the browser, which means it can be different on every element, every component, and every theme state — without a build step.

Declaring and consuming custom properties

A custom property is any property whose name starts with two dashes. By convention, global design values live on :root so every element can reach them:

:root {
  --color-primary: #3a7bd5;
  --color-text:    #1a1a2e;
  --space-base:    1rem;
}

Consume a custom property with var():

.button {
  background-color: var(--color-primary);
  color: #fff;
  padding: var(--space-base) calc(var(--space-base) * 2);
}

The var() call is resolved at the time the browser computes the element's style — just like any other CSS value. No compilation required.

Inheritance: the cascade follows the tree

Custom properties inherit like any other inherited CSS property: a value declared on an ancestor is visible to all its descendants. Declare --color-primary on :root and it reaches every element in the document. Declare it on a more specific selector and the descendants of that element see the new value instead.

:root {
  --color-primary: #3a7bd5;   /* default: blue */
}

.sidebar {
  --color-primary: #7b3ab5;   /* purple inside .sidebar */
}

/* Both rules below read --color-primary, but see different values */
.button { background-color: var(--color-primary); }

A .button outside .sidebar gets blue. The same .button class inside .sidebar gets purple. No extra selector specificity needed — just placement in the tree.

Scope: one attribute change, one palette swap

The inheritance behaviour makes theming almost trivial. A [data-theme] attribute selector can redefine an entire set of design values in one block:

:root {
  --color-bg:      #ffffff;
  --color-text:    #1a1a2e;
  --color-primary: #3a7bd5;
  --color-surface: #f0f4ff;
}

[data-theme="dark"] {
  --color-bg:      #1a1a2e;
  --color-text:    #e8eaf6;
  --color-primary: #7ba7f5;
  --color-surface: #2a2a4e;
}

Now <html data-theme="dark"> — or any subtree with that attribute — switches every component that reads those properties to its dark-mode variant. No duplication of component rules; the components themselves are untouched.

You can scope theme attributes to any element, not just <html>. A single panel in a dashboard can carry data-theme="dark" while the rest of the page stays light. This granularity is impossible to achieve with Sass variables or class-based theming without significant duplication.

The fallback argument

var() accepts a second argument: the value to use when the custom property is not defined in scope:

.link {
  color: var(--color-link, blue);        /* falls back to blue */
  font-size: var(--font-size-sm, 0.875rem);
}

Fallbacks make components more resilient: they work even when a caller has not provided the expected custom properties. They are also useful during development — you can see the fallback value in DevTools when the property is missing, which tells you immediately where the gap is.

Fallbacks can themselves be var() calls:

color: var(--color-accent, var(--color-primary, #3a7bd5));

Runtime updates

Because custom properties are part of the live computed style, they can be changed at runtime. JavaScript can set them on any element:

document.documentElement.style.setProperty('--color-primary', '#e05a2b');

The browser immediately repaints every element that reads --color-primary. No class toggling, no CSS duplication — the same mechanism that handles theming handles dynamic component variants, user-configurable settings, and even animation.

Invalid at computed value time

One subtle trap: a custom property that resolves to a type-incompatible value does not produce a syntax error. The browser accepts the declaration and stores the property. The problem surfaces only when the value is substituted into a real property:

:root {
  --size: "big";   /* stores the string — no error yet */
}

h1 {
  font-size: var(--size);   /* substituted: font-size: "big" — invalid */
}

When the substituted value is invalid, the browser does not leave the previous value in place. Instead it falls back to the property's initial or inherited value. For font-size that is the browser default — not the value you hoped for, and not a red error in the console.

DevTools will show font-size as crossed out with a yellow warning icon in the Styles panel — that is your signal. Always check DevTools when custom-property-based styles silently produce the wrong output.

Where to go next

You have a native theming primitive that the browser understands natively. Next: The Cascade in Production — how real codebases accidentally grow into specificity wars, and how cascade layers (@layer) give teams a way to declare the priority order of their stylesheets explicitly so specificity stops being the deciding factor.

Finished reading? Mark it complete to track your progress.

On this page