What Are CSS Custom Properties and How Do They Enable Runtime Theming?
CSS custom properties inherit and cascade at runtime, enabling dynamic theming that preprocessor variables cannot match. Learn how @property adds type constraints and animation.
Most developers believe CSS custom properties are just ‘variables with better timing’. That framing misses the entire point. A CSS custom property is a user-defined property whose name starts with two dashes (–), and whose value is any valid CSS value, accessed via the var() function. The difference from a Sass $var is not cosmetic: a preprocessor variable is a build-time constant, frozen into static CSS before the page loads. A custom property is a live participant in the cascade, inheriting down the DOM tree and updatable at runtime through the CSSOM. This deep dive shows you exactly how that runtime behaviour works, what breaks, and what you can build with it that a preprocessor cannot.
How the Custom Property Cascade and Inheritance Actually Work
The cascade treats custom properties like any other property: origin, @layer, specificity, and source order all decide which declaration wins. Inheritance is the part that trips people. Custom properties inherit by default. A value set on a parent flows to all descendants unless a descendant overrides it. That mechanism makes design tokens work as a tree: set --color-primary on the root, and every component reads it via var().
The computed value stage matters. A custom property’s specified value is resolved at computed-value time, not at parse time. An invalid value, say, assigning a colour to a property later used as a length, does not fail when the CSS is parsed. It fails only when var() is actually consumed. At that moment the fallback kicks in. This is the single most misunderstood failure mode in custom properties.
Comma-Separated Fallbacks Break Silently
A fallback with multiple values must be space-separated, not comma-separated. var(--color, 255 0 0) is a valid fallback for an RGB triplet. var(--color, 255, 0, 0) is broken. Commas delimit the fallback from the property name, and a comma inside the fallback splits it into an invalid declaration.
Parse-Time Validation Does Not Exist
Invalid values are caught only at computed-value time. Your fallback appears only when the property is actually used. If you never consume the variable, the invalid value sits there silently. Test with @supports (--custom: value) to detect whether custom property syntax itself is supported, and always provide a fallback in var().
Here is a complete sample showing inheritance and fallback, replacing what you would otherwise write with Sass variables that cannot change after build:
:root {
--spacing: 1rem;
--accent: #0066cc;
}
.card {
--spacing: 2rem; /* overrides for this subtree */
padding: var(--spacing, 1rem);
border: 1px solid var(--accent, #333);
}
.card .title {
/* inherits --spacing: 2rem from .card */
margin-bottom: var(--spacing, 1rem);
}
.card .button {
/* falls back to #333 if --accent is unset */
background: var(--accent, #333);
}
Run this in any modern browser. Change --accent on :root via devtools and watch both .card and .button update instantly. That is runtime theming behaviour a preprocessor cannot touch.
@property At-Rule Syntax: Typed Custom Properties and Animation
A plain custom property is a wildcard: it holds any value, and the cascade treats it as a token to be substituted. The @property at-rule changes that by giving the property a type. The syntax descriptor declares what values are valid, the inherits descriptor declares whether the property inherits, and initial-value provides the starting point. Once a property is registered with a type, the browser can compute it, animate it, and enforce validity at parse time rather than at computed-value time.
This is what makes custom properties animatable. Without @property, a transition on a custom property interpolates nothing. The value jumps from one to the other because the browser has no idea what the value means. Register the property as a <color> or a <length>, and the browser can interpolate between two registered values.
Here is a complete sample that replaces the inability to transition custom properties without @property:
@property --progress {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
.bar {
--progress: 0%;
width: var(--progress);
background: #0066cc;
transition: --progress 0.5s ease;
}
.bar:hover {
--progress: 100%;
}
The transition fires because @property tells the engine that --progress is a percentage, so the computed value can be interpolated from 0% to 100%. Without the registration, the browser treats the value as an untyped token and snaps the width. That is the distinction between a custom property as a string and a custom property as a typed, computed value.
CSS Custom Properties vs Preprocessor Variables: The Runtime Axis
The axis that separates these two is not syntax; it is time. Preprocessor variables, Sass $var, Less @var, are build-time constants. The preprocessor compiles them into static values. What ships to the browser is finished CSS. Custom properties are runtime theming tools. They participate in the cascade, inherit through the DOM, and update through the CSSOM via setProperty.
That runtime ability enables patterns no preprocessor can express. A design system can expose a --theme variable on the root, and components consume it. When the user toggles a theme, JavaScript calls document.documentElement.style.setProperty('--theme', 'dark'), and every var() reference recomputes immediately. No class swap. No re-render. No specificity fight.
Runtime Theme Toggle With setProperty
Here is a complete sample that implements a runtime theme toggle with setProperty, replacing a class-swap dark-mode system:
:root {
--bg: #ffffff;
--text: #1a1a1a;
--accent: #0066cc;
}
body {
background: var(--bg);
color: var(--text);
}
<button id="toggle">Toggle Dark</button>
const root = document.documentElement;
const button = document.getElementById('toggle');
button.addEventListener('click', () => {
const isDark = root.style.getPropertyValue('--bg') === '#1a1a1a';
root.style.setProperty('--bg', isDark ? '#ffffff' : '#1a1a1a');
root.style.setProperty('--text', isDark ? '#1a1a1a' : '#ffffff');
});
Clicking the button updates two custom properties. Every component that reads them via var() restyles without any additional JavaScript. The class-swap approach would require you to write selectors for .dark .card, .dark .button, and every other variant. Here, the tokens handle it.
Custom Property Cascade and Inheritance: Scope and Layers
The cascade has two levers that custom properties interact with directly: @scope and @layer. @scope limits selector reach to a DOM subtree. Use it to confine a custom property’s influence without relying on the parent-child inheritance chain. @layer provides explicit priority control: declarations in a later layer win over earlier layers, regardless of specificity. Build a design-system layer that overrides a framework layer without resorting to specificity hacks.
When you set a custom property inside a @layer, its cascade position is determined by that layer’s ordinal position in the @layer list. The inherits descriptor on @property also plays a role. Register a property with inherits: false, and it stops propagating down the tree. That isolates a component’s internal tokens.
Practical Sequence for Design-System Authors
- Declare your design tokens as custom properties on
:rootor a scoping element. - Use
@layer tokens, components, utilitiesto set priority order explicitly. - Inside a component, override tokens locally; inheritance handles the rest.
- Set
inherits: falseon properties that should not leak. - Test with
@supports (--custom: value)for baseline support.
This is the vocabulary you need to defend choices to stakeholders. Custom properties are not variables. They are a cascade-and-inheritance mechanism with runtime mutability. Layers give you the same priority control as specificity hacks but without the fragile selectors. Cascade layer priority is ordinal, not specificity-based. You can override a framework without counting specificity points.
FAQ: Custom Property Cascade and Runtime Behaviour
Why does my custom property not update when I change it in JavaScript?
You are probably setting it on a parent that does not match the inheritance chain, or using a fallback that silently swallows the change. Confirm the parent actually contains the element that reads it via var(). Also verify you are setting it on the element’s style or the documentElement, not on a detached node.
Can I transition a custom property without @property?
No. Without registration, the browser treats the value as an untyped token and cannot interpolate. Register the property with @property and a syntax descriptor. Then the browser can compute and animate it. The transition needs a before and after value that are both valid for the declared syntax.
What is the difference between a fallback and an initial-value?
A fallback is the second argument in var(--x, fallback), used when --x is unset or invalid at computed-value time. An initial-value is what @property declares as the starting point before any author sets it. They serve different purposes: fallback is per-use, initial-value is per-property registration.
How do cascade layers interact with custom properties?
A declaration in a later @layer wins over an earlier one, regardless of specificity. Set --accent in a base layer and again in a components layer, and the components layer wins. Structure token overrides without fighting specificity or source order.
Who This Subject Suits, and Who Should Turn Back
This subject suits the working front-end developer who writes CSS daily and needs to know what shipped, what is safe to use, and what the fallback is. It suits the design-system author who needs precise specification behaviour and the vocabulary to defend choices. It suits the technical writer who needs accurate, sourced statements. The exact names: CSS Custom Properties for Cascading Variables Module Level 1, the @property at-rule, the syntax descriptor, the inherits descriptor.
It does not suit someone learning to code from zero. Go to web.dev/learn/css or the MDN CSS first-steps guide, then return. It does not suit someone debugging a React state bug; this is not a JavaScript topic. It does not suit someone comparing CSS-in-JS libraries; that is a JavaScript tooling question. It does not suit anyone expecting a flexbox tutorial. Flexbox is a one-dimensional distribution system. Custom properties are a cascade-and-inheritance mechanism. If you need to animate a value that changes after load, if you need design tokens that respond to user choice, if you need to control priority without specificity hacks, this is your territory. If you need static values that never change, use a preprocessor and save yourself the runtime cost.