CSS variables in practice: scope, fallbacks and theming
Custom properties inherit and cascade, which is what separates them from Sass variables. An invalid value falls back further than you expect.
A CSS custom property is a name beginning with two dashes, read back with var():
:root {
--brand: #7c4dff;
}
.button {
background: var(--brand);
}
They are usually called CSS variables, and the more useful description is that they are inherited properties whose value happens to be arbitrary text. Everything surprising about them follows from that.
They cascade and inherit
This is the difference from a Sass or Less variable, and it is not a small one. A preprocessor variable is substituted at build time and does not exist in the browser. A custom property is live: it inherits down the tree, it can be redefined at any level, and it can be changed at runtime.
Measured, with --brand defined at :root and redefined on a .card:
:root { --brand: #7c4dff; }
.card { --brand: #1a9c63; }
| Element | Resolved colour |
|---|---|
#a, outside the card |
rgb(124, 77, 255) |
#b, inside the card |
rgb(26, 156, 99) |
A child of .card reading --brand |
#1a9c63 |
Same declaration, color: var(--brand), two different results depending on where the element sits. The child that never declared anything inherited the card's value.
That is the whole mechanism behind theming. You do not rewrite the components; you redefine the variable somewhere above them.
Scoping is just a selector
Because they inherit, you scope a custom property by declaring it on whatever element should own it:
:root { --gap: 1rem; } /* the default, everywhere */
.compact { --gap: 0.5rem; } /* tighter, inside .compact */
.spacious { --gap: 2rem; } /* looser, inside .spacious */
.stack > * + * { margin-top: var(--gap); }
One rule reads --gap, and three contexts supply different values. Adding class="compact" to a wrapper changes every descendant, with no new CSS for the component itself.
This is the pattern that makes custom properties worth using beyond colour tokens: the component declares what it needs, and the context decides what to supply.
Fallbacks
var() takes a second argument used when the property is not defined:
color: var(--nope, rgb(1, 2, 3));
Measured, that resolved to rgb(1, 2, 3). The fallback is not an error path; it is a normal way to give a component a sensible default that a consumer can override:
.button {
background: var(--button-bg, var(--brand, #7c4dff));
}
Fallbacks nest, so that reads as "the button's own colour, or the brand colour, or this hard-coded purple". Everything after the first comma is the fallback, which means a fallback can itself contain commas:
font-family: var(--font, "Inter", system-ui, sans-serif);
An invalid value falls back further than you expect
This is the behaviour that produces genuinely confusing bugs, and it is worth seeing measured.
#invalid {
--w: notalength;
width: var(--w);
}
notalength is not a valid width. You might expect the declaration to be dropped, leaving the element at its previous width. Measured, the element rendered at 960px, which is its parent's width, meaning width resolved to auto.
The rule is called invalid at computed-value time. Because the browser cannot know whether --w is valid until it substitutes it, the declaration is not discarded at parse time like an ordinary typo. Instead it is accepted, and when substitution produces nonsense the property falls back to its inherited value if it inherits, or its initial value if it does not.
The practical consequence: a typo in a custom property does not leave the previous rule in place. It resets the property to its default, which can look like a completely unrelated rule stopped working.
@property is the modern fix, letting you declare a type and a genuine fallback:
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 100px;
}
Now an invalid value falls back to 100px rather than to auto, and the property can also be animated, which plain custom properties cannot.
They work with calc
:root { --space: 8px; }
.thing { width: calc(var(--space) * 10); }
Measured at 80px. Custom properties compose with calc(), which is where a spacing scale becomes practical:
:root { --space: 0.5rem; }
.card { padding: calc(var(--space) * 3); }
One number controls the rhythm of a whole design, and changing it rescales everything proportionally.
Note that the value substituted is raw text. --space: 8 with width: calc(var(--space) * 1px) also works, and --space: 8px with width: calc(var(--space) * 10) works too, but --space: 8px used as calc(10 * var(--space)) and then multiplied again by a length will fail, because you cannot multiply two lengths. The text substitution is literal and the maths rules still apply afterwards.
Reading and writing from JavaScript
Because they are live, JavaScript can read and change them:
const root = document.documentElement;
getComputedStyle(root).getPropertyValue("--brand"); // " #7c4dff"
root.style.setProperty("--brand", "#1a9c63");
Two details. getPropertyValue returns the raw text including leading whitespace, so .trim() it before comparing. And setting a property on an element applies to that element and everything inside it, so setting on documentElement is how you change a value globally.
This is the mechanism behind runtime theming, user-adjustable settings, and passing values from JavaScript into CSS animations without touching individual style properties.
Where they cannot be used
Three limits worth knowing before you design around them.
Not in media query conditions. @media (min-width: var(--bp)) does not work. Breakpoints stay as literal values, which is the most requested missing feature in this area.
Not as part of a property name. You cannot build var(--side)-margin. Substitution happens in values, not in the property position.
Not in selectors. They are values, not identifiers.
For breakpoints specifically, the workaround is to keep them in one place in a build tool, or to lean on container queries and intrinsic sizing so that fewer hard breakpoints are needed at all.
Dark mode, the practical pattern
The clearest demonstration of why inheritance matters:
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14121a;
--text: #f2f2f2;
}
}
body { background: var(--bg); color: var(--text); }
Every component reads the tokens. Neither the media query nor any component knows about the other. Adding a manual toggle is one more selector:
[data-theme="dark"] {
--bg: #14121a;
--text: #f2f2f2;
}
Define the light palette on bare :root so it is always the baseline, then override the tokens in both the media query and the attribute selector. A colour whose only definition lives inside a dark-mode block has no light value at all, which is the usual cause of an unreadable page in one of the two themes.
Animating them
A plain custom property cannot be transitioned, because the browser treats its value as arbitrary text with no known type and therefore nothing to interpolate.
:root { --angle: 0deg; transition: --angle 300ms; } /* does nothing */
@property fixes this by declaring the type:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.spinner { transition: --angle 300ms linear; }
.spinner:hover { --angle: 360deg; }
Now the browser knows it is an angle and can interpolate it. This unlocks a category of effect that previously needed JavaScript, most visibly animated conic gradients and gradient borders, where the gradient itself cannot be transitioned but the angle feeding it can.
The same declaration also gives you a real fallback for the invalid-value case above, and stops the property inheriting when you do not want it to. If you are writing custom properties that hold anything other than a colour or a plain string, @property is worth the four lines.
Debugging them
Three techniques, in order of how often they are needed.
Read the resolved value in the console:
getComputedStyle(el).getPropertyValue("--brand").trim();
Run it on the element that looks wrong rather than on :root, since the whole point is that the value depends on where you are in the tree.
Check the Computed panel in DevTools, which lists custom properties alongside everything else and shows which rule supplied each one. That answers "where is this value coming from" directly, and it is the fastest route when a token has been redefined somewhere unexpected.
Suspect a typo when a property resets rather than keeps its old value. As described above, an invalid substitution falls back to inherited or initial, so a misspelled variable name looks like a completely different rule broke. If width suddenly behaves as auto, read the variable it depends on.
One more that catches people: a custom property defined on an element does not apply to that element's own siblings, only to itself and its descendants. Defining --gap on the first child of a container and expecting the container to see it will not work. Define it on the common ancestor.
Are they slow
A reasonable question, since every var() is a lookup at render time rather than a value baked in at build time.
In practice, no. Custom properties are resolved during style computation, which browsers are heavily optimised for, and a page with hundreds of tokens performs indistinguishably from one with none.
The one case worth knowing: changing a custom property on :root invalidates styles for everything that reads it, which can be the whole page. Doing that inside a scroll handler or an animation frame can cause real work on every frame. Change it on the smallest element that needs it, and prefer animating transform and opacity directly over driving them through a root-level variable.
For theming, where the change happens once when a user flips a switch, the cost is irrelevant.
Naming them
Two conventions, and the difference matters more than it looks.
Descriptive names say what the value is: --purple-500, --space-4. Semantic names say what it is for: --color-primary, --card-padding.
A design system usually wants both, layered:
:root {
--purple-500: #7c4dff; /* the palette */
--color-primary: var(--purple-500); /* the meaning */
}
Components read only the semantic layer. Rebranding then means changing one line in the palette, and a component never has to know which purple it is using.
Passing values from HTML
Because a custom property can be set in a style attribute, HTML can hand data straight to CSS:
<div class="bar" style="--pct: 72%"></div>
.bar::after { width: var(--pct); }
No class per value, no inline width, and the CSS still owns how the number is used. The same technique drives the stagger delay pattern, avatar colours generated per user, and progress indicators, and it keeps presentation logic in the stylesheet where it can be themed.
It is the tidiest interface between a template and a stylesheet: the template supplies data, the stylesheet decides what to do with it. Compare that with setting style="width: 72%" directly, which hard-codes the decision in the markup and cannot be overridden by a theme.
The one rule is to keep the values data rather than styling. --pct: 72% is data; --bar-css: "width: 72%" is not, and custom properties cannot hold declarations anyway.
Three common mistakes
Expecting a preprocessor variable's behaviour. Custom properties are not substituted at build time. They inherit, they cascade, and a component can be handed a different value depending on where it is used, which is a feature rather than an inconsistency.
A typo silently resetting a property. An invalid substituted value falls back to inherited or initial, not to the previous declaration. If a property seems to have stopped working entirely, check the variable it reads.
Defining a token only inside a dark-mode block. It then has no value in light mode and falls back to initial. Define the full palette on bare :root and override it.
Quick reference
:root { --brand: #7c4dff; } /* define */
color: var(--brand); /* use */
color: var(--brand, #7c4dff); /* with a fallback */
.card { --brand: green; } /* rescope for a subtree */
width: calc(var(--space) * 10); /* compose with calc */
@property --w { /* typed, animatable */
syntax: "<length>";
inherits: false;
initial-value: 100px;
}
getComputedStyle(el).getPropertyValue("--brand").trim();
el.style.setProperty("--brand", "#1a9c63");
Want to build a themeable component and flip it at runtime? Start with the CSS track, or read CSS units for the values these tokens usually hold.
More from the blog
z-index not working? It is the parent, not the number
A child with z-index 9999 loses to a sibling with z-index 1 when the parent has opacity. Same markup without it, and 9999 wins.
Read moreCSS units: px, rem, em, vh and when to use each
CSS units compared: nested em compounds to 67.5px where rem stays at 24px. That difference is the whole reason to prefer one over the other.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.