--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);
}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;
}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);
}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);
}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));
}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);
}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;
}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);
}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
- HTML and CSS topic hub
- HTML and CSS multiple-choice questions
- CSS dark mode with prefers-color-scheme
- CSS cascade layers
- MDN: using CSS custom properties