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.
- 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 CSS custom property 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.
Lab — Profile with DevTools
Use Chrome DevTools to measure the rendering pipeline of a deliberately slow page, identify the bottlenecks, and apply targeted fixes.
The Cascade in Production
In real codebases, the cascade becomes the cascade layer — predictable ordering of stylesheets that eliminates specificity wars without reaching for !important.