CSS Custom Properties: Variables, Fallbacks, and Themes
CSS custom properties give reusable values a name. A declaration beginning with two dashes stores the value, and the var() function reads it inside another property value.
:root {
--color-accent: #2563eb;
}
.button {
background: var(--color-accent);
} Think of each custom property as a label attached to a CSS value. The label can begin at the document root, then a component or theme can attach a different value to the same label within its own scope.
What CSS Custom Properties Do
Custom properties are often called CSS variables, but their behavior comes from CSS. They follow the cascade, inherit by default, and resolve for the element where they are used. That makes the same name useful across a document without forcing every component to receive the same final value.
The browser keeps the stored value as a sequence of tokens until var() substitutes it into a real declaration. A color label can therefore feed color, background, or border-color, provided that the final substituted value is valid for the receiving property.
Document-wide custom-property defaults usually belong on the :root element. Component-specific values belong on the component container, where inheritance carries them only through the part of the document that needs the override.
Define Shared Values at the Document Root
The :root pseudo-class matches the document's root element, which is html in an HTML page. Values declared there are available to descendants through inheritance.
:root {
--color-text: #1f2937;
--color-accent: #2563eb;
--space-md: 1rem;
--radius-md: 0.5rem;
} Names should describe the value's role when that role is stable. --color-accent remains useful if the accent changes from blue to orange, while --blue becomes misleading after the same edit.
Read a Custom Property Value
Pass the complete custom property name to var(). The browser replaces that function with the computed value available on the current element.
.card {
color: var(--color-text);
border: 1px solid var(--color-accent);
padding: var(--space-md);
border-radius: var(--radius-md);
} Changing --color-accent at the root updates every declaration that reads the label, unless a nearer rule overrides it.
Add a Fallback Value
The second argument to var() is used when the requested custom property is missing or resolves to a CSS-wide keyword that makes it invalid. The fallback can be a direct value or another var() expression.
.alert {
color: var(--color-danger, #b91c1c);
padding: var(--alert-space, var(--space-md, 1rem));
} The alert uses #b91c1c when --color-danger is undefined. Its padding tries a component value, then the shared spacing label, then a final direct length.
Override a Value in One Scope
Redeclare the same label on a container to change descendants inside that subtree. The rest of the document continues to inherit the root value.
:root {
--color-accent: #2563eb;
}
.warning-panel {
--color-accent: #c2410c;
}
.panel-link {
color: var(--color-accent);
} A panel link inside .warning-panel becomes orange, while the same link class elsewhere remains blue. The label stays the same because its job remains "accent color" in both scopes.
Build a Theme Switch
A theme can override a small set of labels on the root element. Components keep reading the same names, so their rules do not need separate light and dark versions.
:root {
--page-bg: #eef3f0;
--page-text: #1f2937;
--link-color: #1d4ed8;
}
:root[data-theme="dark"] {
--page-bg: #17211d;
--page-text: #e5eee9;
--link-color: #93c5fd;
}
body {
color: var(--page-text);
background: var(--page-bg);
}
a {
color: var(--link-color);
} <html data-theme="dark">
<body>...</body>
</html> Changing the data-theme attribute switches the values inherited by the page. JavaScript can update that attribute after a reader selects a theme, while CSS remains responsible for the visual rules.
Read and Write Them from JavaScript
This is the capability that separates a custom property from a preprocessor variable, and it is worth being precise about. A Sass variable is gone by the time the browser sees the stylesheet. A custom property is a live value in the document, so scripts can read it and change it while the page runs.
const root = document.documentElement;
// read the current value
const brand = getComputedStyle(root)
.getPropertyValue("--color-brand")
.trim();
// set a new one
root.style.setProperty("--color-brand", "#7c3aed"); The .trim() is defensive rather than decorative. Browsers have differed over whether the returned value keeps the whitespace that followed the colon in the source, so a comparison against a bare string can fail in one engine and pass in another. Trimming costs nothing and removes the inconsistency.
setProperty on documentElement writes an inline style on <html>, so the new value inherits down to everything and overrides the stylesheet's :root block. Set the same property on a single element instead and only that subtree changes, which is how one live control can retheme a component without touching the rest of the page.
Worth knowing before reaching for it: the value you set is a string and CSS re-parses it on use, so an invalid value does not throw. It simply makes any declaration that consumes it invalid at computed-value time, which usually shows up as an element falling back to an inherited or initial value rather than as an error anywhere.
Registering a Typed Property
By default the browser treats a custom property as an unrestricted string. It does not know that --angle: 0deg is an angle, so it cannot interpolate between two values. The transition is not ignored, it is applied discretely: the value flips from one to the other partway through instead of sweeping between them, which looks like the animation failing.
@property registers a type and fixes that:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.dial {
--angle: 0deg;
transition: --angle 300ms ease;
transform: rotate(var(--angle));
}
.dial:hover {
--angle: 90deg;
} syntax names the type and inherits decides whether the value passes down the tree; both are always required. initial-value is required too for any concrete syntax like <angle>, and is only optional when the syntax is the universal *, which is the case that opts back out of typing. With a real type in place the transition has two endpoints to interpolate between and the rotation sweeps smoothly.
Registration also buys validation. An invalid assignment falls back to the declared initial-value rather than making the whole declaration invalid, which is a more predictable failure than the untyped behaviour described above. Support for @property arrived later than the rest of this page, so check it against your own support target before depending on it for anything load-bearing.
Common Pitfalls & Debugging
A Value Becomes Invalid at Computed-value Time
Symptom: a declaration falls back to its inherited or initial value. Cause: the custom property exists, but its substituted value is invalid for the receiving property. Fix: inspect the computed declaration and store a value of the correct type.
:root {
--text-color: 16px;
}
.notice {
color: blue;
color: var(--text-color);
} The browser accepts 16px as custom-property data before it knows where the label will be used. Once substituted into color, the value becomes invalid at computed-value time. A var() fallback does not help here because the custom property exists.
Inheritance Changes More Elements than Expected
Symptom: a component override affects deeper descendants. Cause: two-dash custom properties inherit by default. Fix: place the override on the narrowest useful container or redeclare the label where inheritance should stop.
Custom Properties Do Not Work in Media Query Conditions
Symptom: a media query containing var() never matches. Cause: the function substitutes values only inside CSS property declarations. Selectors and query conditions cannot use that substitution. Fix: write the breakpoint directly in the condition and use custom properties inside its rules.
/* Invalid */
@media (min-width: var(--breakpoint-md)) {
.sidebar { display: block; }
}
/* Valid */
@media (min-width: 48rem) {
.sidebar { --sidebar-space: 1.5rem; }
} The valid query keeps the breakpoint as a direct length. The custom property remains inside the declaration block, where CSS allows it to store a value for descendant rules.
Conclusion
Name values that repeat or change by scope, then let the cascade carry those names to the elements that use them. Keep one-off values directly in their declarations.
Frequently Asked Questions
Are CSS custom properties the same as JavaScript variables?
No. CSS custom properties store values that participate in the cascade and can be substituted into property values with var(). JavaScript variables belong to the JavaScript language, although scripts can read and change custom properties through the CSS object model.
Do CSS custom properties inherit?
Custom properties declared with the two-dash syntax inherit by default. A child uses the nearest value available through the cascade, which makes scoped component and theme overrides possible.
When should a project use custom properties?
Use them for meaningful values that repeat, vary by scope, or change together. Shared colors, spacing steps, component dimensions, and theme values are good candidates, while a one-off measurement can stay directly in its declaration.
Do custom properties work the same way in an inline style attribute as in a stylesheet?
Yes. Setting a custom property in a style attribute applies it to that element and its descendants exactly as a stylesheet rule would. That suits a value coming from server-rendered data, such as a per-item accent colour, without writing a new rule for each instance.
Can calc() use a custom property inside its expression?
Yes. calc(var(--space-md) * 2) substitutes the stored value before the calculation runs, so a spacing scale can derive related sizes from one base value. The substituted value must still resolve to a type calc() accepts, such as a length or a number.
Does using many custom properties slow down a stylesheet?
Custom properties add negligible overhead at typical stylesheet sizes; the browser resolves them during normal style computation rather than as a separate pass. Readability and maintainability, not performance, should decide how many named values a project defines.
Related Typography and Color Guides
- Return to the CSS typography and color guide.
- Choose reusable values with CSS colors and units.
- Name type settings from the CSS fonts and text guide.
Sources
-
[1]
Using CSS custom properties (variables)(developer.mozilla.org)
-
[2]
var() CSS function(developer.mozilla.org)
-
[3]
Introducing the CSS Cascade(developer.mozilla.org)
Read Next
Learn CSS typography and color through color formats, length units, font and text properties, custom properties, and shadows.
The core HTML elements that give a page its shape: the document skeleton, headings and text, lists, links, and images, with small valid examples.
Learn CSS through selectors, the cascade, the box model, flexbox and grid layout, typography, color, custom properties, and responsive design.