Ch. 5 · HTML & CSS

CSS Custom Properties (Variables): Inheritance, var() and Theming

How CSS variables inherit, when var() resolves, why bad values fail silently, and how to scope theme tokens so components can override them.

~5 min readadvancedupdated Oct 7, 2026

--brand: #1d4ed8 looks like a Sass variable, and that’s the source of most confusion about it. A Sass variable is a find-and-replace that happens before the browser sees the CSS. A custom property is a real CSS property: it lives on elements, it inherits down the tree, it participates in the cascade, and it can change at runtime with a class toggle or a line of JavaScript. Most of what makes custom properties useful for theming, and most of the bugs, follow from that.

Inheritance is the whole mechanism

Custom properties inherit by default. Set one on :root and every element can read it. Set a different value on an element, and that element and its descendants see the new value, while everything else keeps the old one:

:root {
  --surface: #ffffff;
  --text: #18181b;
}

.promo {
  --surface: #fef3c7;
}

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

A .card anywhere on the page renders with a white background. A .card inside .promo renders pale yellow. You didn’t write a .promo .card rule; the card reads whatever --surface it inherits. That’s the theme boundary: any element can redefine tokens for its subtree, and components inside it adapt without knowing.

Dark mode is the same idea at the root:

:root[data-theme='dark'] {
  --surface: #18181b;
  --text: #f4f4f5;
}
CSS

Toggle data-theme on <html> and every var(--surface) on the page re-resolves. No component CSS changes.

When var() is resolved

This is where people’s Sass intuition fails. var() is substituted on each element at computed-value time, using that element’s own value of the variable. It isn’t substituted where the variable is declared.

:root {
  --size: 16px;
  --padding: calc(var(--size) / 2);
}

.large {
  --size: 24px;
}

.box {
  padding: var(--padding);
}
CSS

What padding does .box get inside .large? Many people expect 12px. It’s 8px. --padding gets its computed value on :root, where var(--size) is replaced with 16px, leaving calc(16px / 2). That computed value, with no reference to --size left in it, is what descendants inherit. .large changes --size but not --padding, so .box inherits the root’s --padding and ends up with 8px.

The fix is to define the derived variable where the override happens, or on the element that uses it:

.box {
  --padding: calc(var(--size) / 2);
  padding: var(--padding);
}
CSS

Now each .box substitutes its own inherited --size, and inside .large it gets 12px. The rule of thumb: a variable that depends on another variable should be declared on the element that consumes it (or on a selector that matches it), not hoisted to :root.

Fallbacks and invalid values

var() takes a fallback for when the property is not defined (or is the guaranteed-invalid value initial):

.button {
  background: var(--button-bg, var(--brand, #1d4ed8));
}
CSS

That reads --button-bg, falls back to --brand, then to a literal. It’s a good pattern for component APIs: components expose their own variable, default to a global token, and finally to something safe.

The fallback does not protect you from a bad value, though. Custom properties accept almost any token stream, so --gap: red; is perfectly valid as a custom property. The problem appears only when it’s substituted:

.list {
  gap: 1rem;
  gap: var(--gap);
}
CSS

If --gap is red, the second declaration parses fine (the parser can’t know what var() will produce), so it wins the cascade, and the first one is discarded. At computed-value time gap: red is invalid, and the declaration becomes “invalid at computed-value time”. The property then behaves as unset: gap isn’t inherited, so it falls back to its initial value, normal, which is zero in flex and grid. You don’t get 1rem, even though 1rem was written right above it. This is different from an ordinary typo like gap: rde;, which is dropped at parse time, so the earlier declaration survives.

DevTools shows the declaration as applied, with the variable’s value visible on hover, which makes this hard to spot. When a variable-driven property mysteriously has its default value, check the substituted value.

@property: typed variables

@property registers a custom property with a syntax, an initial value and an inheritance flag:

@property --progress {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.bar {
  background: linear-gradient(90deg, #1d4ed8 var(--progress), #e4e4e7 0);
  transition: --progress 400ms ease-out;
}
CSS

Three things change. An invalid assignment (--progress: red) now falls back to 0% instead of making the background declaration invalid. The property can be animated, because the browser knows how to interpolate a percentage; an unregistered property just flips at the halfway point. And inherits: false stops it leaking into descendants, which is often what you want for component-internal state. All current major browsers support @property.

Designing the boundaries

A token setup that holds up has two layers. Global tokens name colours and spacing by role: --color-surface, --color-text, --color-accent, --space-2. Component tokens name the component’s adjustable parts and default to globals:

.button {
  --button-bg: var(--color-accent);
  --button-text: var(--color-on-accent);
  --button-radius: 6px;

  background: var(--button-bg);
  color: var(--button-text);
  border-radius: var(--button-radius);
}

.button.danger {
  --button-bg: var(--color-danger);
}
CSS

A theme switch changes a dozen global tokens on the root. A variant changes component tokens on the component. Neither has to know the other’s selectors, and specificity battles mostly go away because overrides set variables instead of re-declaring background.

Two cautions. Don’t name tokens after their value (--blue-600 used as “the brand colour”), because the dark theme will end up with --blue-600: #93c5fd and nobody will trust the names. And remember that setting a variable on an element affects every descendant: a variable set on a modal’s root to restyle its buttons also restyles any button component rendered inside the modal’s content.

Interview practice

A design system defines :root { --space: 8px; --gap: calc(var(--space) * 2); }. A compact table sets .compact { --space: 4px; }, expecting its rows to use an 8px gap, but they still get 16px. Then someone writes --space: 4 (no unit) on another container and the gap disappears entirely. Explain both.

Answer

The first is substitution timing. --gap is computed on :root, where var(--space) is replaced by 8px, so the inherited value of --gap is calc(8px * 2) everywhere. Changing --space lower in the tree doesn’t revisit a variable that was already resolved higher up. Move the --gap definition to the components that use it (or to a selector like .stack), so each one computes it from its own inherited --space, or override --gap as well inside .compact.

The second is an invalid value at computed-value time. calc(4 * 2) is a number, not a length, so wherever gap: var(--gap) resolves to it, the declaration is invalid after substitution. The cascade has already chosen it over any earlier gap, so the property falls back to its initial value, normal, which is a zero gap in a flex container. Registering --space with @property and syntax: '<length>' would make the bad assignment fall back to the registered initial value instead, and lint rules for units help catch it earlier.

Keep going

More in HTML & CSS

read ✓HTML & CSS · hard

HTML Data Tables and Header Relationships

How th, scope, caption and headers/id tell a screen reader which headers describe a cell, with a two-level pricing table worked through.

~7 min readread →
read ✓HTML & CSS · easy

HTML Buttons Versus Links for Actions

Links go somewhere and buttons do something. What each element gives you for free, and what breaks when a div or an a tag fakes the other.

~6 min readread →
esc