CSS custom properties let you name a value once and reuse it everywhere, and unlike a build-time constant they are real values inside the browser. They inherit, they can be redefined on any element, and they survive media queries and themes, which is why CSS variables beat a stylesheet full of repeated literals.
What makes a custom property different from a plain value?
A custom property is any property whose name starts with two hyphens. It holds an arbitrary token stream and is declared with ordinary declaration syntax.
:root {
--brand: #2563eb;
--gap: 1rem;
--card-shadow: 0 1px 2px rgb(15 23 42 / 0.08), 0 8px 24px rgb(15 23 42 / 0.06);
}
.button {
background: var(--brand);
padding: var(--gap);
box-shadow: var(--card-shadow);
}
The substitution happens when the browser computes styles, not when it parses them. That single fact explains most of the behaviour people find surprising. A custom property can hold a partial value such as a whole shadow list or a gradient, and it can be referenced from any property at all, including shorthand ones.
Names are case-sensitive, and a variable may not be used inside a url() or inside a media query condition. If you need a media query to react to a value, that value has to come from a different mechanism.
How does the cascade resolve a custom property?
A custom property behaves like any other property: the last declaration that applies to an element wins, and the winning value then inherits to that element's children. Redeclaring --brand on a container changes it for that container and everything inside it, without touching any rule that uses var(--brand).
.tier--featured {
--brand: #7c3aed; /* only this card and its children see the new value */
border-color: var(--brand);
}
/* still the original colour everywhere else */
.other-button { background: var(--brand); }
Two rules setting the same variable on the same element are resolved by the usual cascade rules, including source order and specificity. A value declared on :root is inherited by the whole document, so anything that re-declares the same name closer to the element overrides it for that branch of the tree. That makes custom properties the cleanest way to implement a theme.
How do you scope variables to one component?
Give a component its own block of variables on its own class, then let the parts inside it use those names. The component can then be dropped anywhere on the page and still look right, because nothing inside it depends on a global name.
<article class="tier tier--featured">
<h3 class="tier__name">Studio</h3>
<p class="tier__price">19<span>/month</span></p>
<ul class="tier__features">
<li>10 projects</li>
<li>Priority email support</li>
</ul>
<a class="cta" href="/signup">Start trial</a>
</article>
.tier {
--tier-bg: #ffffff;
--tier-fg: #0f172a;
--tier-accent: #2563eb;
--tier-radius: 12px;
background: var(--tier-bg);
color: var(--tier-fg);
border-radius: var(--tier-radius);
border: 1px solid #e2e8f0;
}
.tier--featured {
--tier-bg: #f8fafc;
--tier-accent: #7c3aed;
border-color: var(--tier-accent);
}
.tier .cta {
background: var(--tier-accent);
color: #ffffff;
}
Notice how the featured tier changes three values and every interior element follows. No rule inside the component needed to know which variant it was in, and the base tier rule never mentions the variant at all.
How do you theme a whole site with variables?
Because the tokens are inherited, a theme is nothing more than a different set of values on a common ancestor. You can drive it from the operating system setting, from a class on the root element, or from a user-controlled attribute, and all three can coexist.
:root {
color-scheme: light;
--bg: #ffffff;
--fg: #0f172a;
--muted: #475569;
--accent: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #0f172a;
--fg: #f1f5f9;
--muted: #94a3b8;
--accent: #60a5fa;
}
}
[data-theme="sepia"] {
--bg: #f4ecd8;
--fg: #3b2f2f;
--muted: #6b5b4b;
--accent: #9a3412;
}
body { background: var(--bg); color: var(--fg); }
A manual theme has to win over the system preference, so put the attribute rule after the media query in source order. Where browsers support light-dark(), an even shorter option is a single token per colour that switches internally, paired with a color-scheme declaration on the root.
What happens when a variable is missing?
This is the behaviour that catches people. If a rule uses var(--missing) and nothing defines that variable, the declaration is invalid at computed-value time. The property then falls back to its inherited value if it inherits, or to its initial value if it does not. width becomes auto; color becomes the inherited colour.
The fix is the fallback argument, which is used whenever the variable is missing or holds the guaranteed-invalid value.
.panel {
background: var(--panel-bg, #f8fafc);
padding: var(--panel-pad, 1rem);
}
A subtlety worth remembering: a variable set to an empty value still counts as defined, so var(--x, 1rem)
There is a related trap in the other direction. A variable that holds an invalid value poisons the whole declaration, so --gap: 10 px followed by gap: var(--gap) makes the gap invalid, which drops it back to its initial value, rather than producing a warning.
What do typed custom properties add?
Registering a custom property with @property gives it a type, an initial value and a defined inheritance behaviour. It also makes the property interpolable, which is what lets a keyframe animation change it smoothly instead of flipping at the halfway point.
@property --tier-progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.tier { --tier-progress: 0.72; }
The syntax descriptor is the interesting one. If the value assigned does not match the declared type, the declaration is ignored and the initial value is used, so a typo degrades to something predictable instead of breaking the layout.
What to remember
- Custom properties are resolved by the browser at computed-value time, so they hold any token stream and work in any property.
- They inherit, and the cascade decides which declaration wins for a given element.
- Scope a component's values on its own class so it can be reused anywhere on the page.
- A theme is a different set of values on a common ancestor, not a different set of rules.
- A variable with no value makes the whole declaration invalid, so give fallbacks where a value might be absent.
- Register tokens with
@propertywhen you want a type, an initial value or smooth interpolation.
Try it yourself: Build a three-tier pricing table where each tier card declares its own --tier-accent, --tier-bg and --tier-price on the card class, and every element inside the card uses those names. Add a data-theme="sepia" attribute on the root element that redefines only the page-level tokens, then add a var(--tier-accent, #2563eb) fallback on the card border and delete the accent from one tier to confirm the border falls back to blue instead of disappearing.