Custom Properties
Inherited properties holding a token stream, substituted at computed-value time — which makes them dynamic, scopeable per element, and capable of invalidating a very large subtree at once.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What is a CSS custom property actually — a variable, a property, or something the cascade participates in — and what does changing one cost?
Someone wants to change a theme, a spacing scale or one component's accent colour without shipping a second stylesheet or re-rendering the application.
They are variables. The build tool substitutes them, like a preprocessor variable, and using more of them is free.
Nothing is substituted at build time. They are real properties on real elements, resolved in the browser, per element, at computed-value time — which is why the same var(--gap) yields different values in different parts of the tree.
- Nothing is substituted at build time. They are real properties on real elements, resolved in the browser, per element, at computed-value time — which is why the same
var(--gap)yields different values in different parts of the tree. - They inherit. A preprocessor variable is lexically scoped to where it was declared; a custom property is scoped to a subtree and can be overridden anywhere inside it. That is the feature, and it is nothing like the mental model people bring.
- Changing one on
:rootinvalidates style for the subtree that could depend on it — which, on the root, is the document. Cheap to write, potentially the most expensive style operation the page performs (Style Invalidation). - An unregistered custom property has no type.
--gap: 1remand--gap: bananaare equally valid at parse time; the failure surfaces only at substitution, and it takes the *whole declaration* with it rather than falling back to a previous value. - They cannot be used everywhere a preprocessor variable can. Not in a media query condition, not as part of a property name, not as a selector — because substitution happens after parsing, not before it.
What is actually happening
In the browser, not in the framework.
- They are properties.
--brandis a real, inherited property with a value. It participates in the cascade like any other: origin, layers, specificity, order all apply (The Cascade). - The value is an almost-uninterpreted token stream. The parser validates that it is balanced tokens and stores it. No type checking happens until it is substituted, which is what allows a custom property to hold a whole declaration fragment, a comma-separated list, or nonsense.
- `var()` substitutes at computed-value time. By the time layout runs,
var()is gone; the property holds a concrete computed value. This is why a custom property change costs style recalculation on everything that reads it, and why it can be animated only when the browser knows its type. - Invalid at computed value time (IACVT). If substitution produces something the property cannot accept, the declaration does not fall back to an earlier one — it becomes
unset. For an inherited property that means it inherits; for a non-inherited one it takes the initial value. Fixing this is what thevar(--x, fallback)second argument is for. - The guaranteed-invalid value. An unset custom property has a special empty value that makes any
var()referencing it fall back, or become IACVT if there is no fallback.--x: initialdeliberately restores that state. - `@property` registers a type. Declaring
syntax,inheritsandinitial-valuegives the property a real type, a real initial value, an inheritance setting you choose, and — because the browser now knows how to interpolate it — the ability to be transitioned and animated (Cheap and Expensive Animation). - They are readable and writable from script.
el.style.setProperty('--x', v)andgetComputedStyle(el).getPropertyValue('--x')work, which makes them the standard bridge between application state and style without touching a stylesheet (Design Tokens).
What this makes the browser do
And which of it is avoidable.
- Storing the token stream on every element that has a declaration for it, plus the inherited value on every descendant. Custom properties are part of computed style, so a large token set on
:rootis memory on every element that inherits it. - Substituting at computed-value time for every element and every declaration that references one — including the transitive case where a custom property's value references another.
- Invalidating the subtree when one changes. Engines maintain dependency information so they can avoid restyling elements that provably do not read the property, but the conservative case is the whole inheriting subtree (Style Invalidation).
- For registered properties, additionally validating against the declared syntax and, during a transition, interpolating between two typed values on every frame.
- The avoidable half: putting a frequently-changing property on
:rootwhen only one component reads it. Declaring it on the component's own element scopes the invalidation to that component.
An inherited property that happens to hold tokens
Every confusing behaviour in this lesson follows from one sentence: a custom property is an inherited property whose value is an unvalidated token stream, substituted at computed-value time. Scoping, theming, the IACVT cliff and the invalidation cost are all consequences.
Because it is a property, it cascades. Because it inherits, it is scoped to a subtree rather than to a file. And because substitution happens per element after the cascade has run, the same var(--gap) in one rule produces different values in different parts of the document — which is the thing a preprocessor variable fundamentally cannot do.
1:root {2 --brand: oklch(0.6 0.18 260);3 --gap: 1rem;4}5 6/* Scoped to a subtree, not to a file. Everything inside .compact7 sees a different --gap, including components that never heard of it. */8.compact { --gap: 0.5rem; }9 10.stack > * + * { margin-block-start: var(--gap); }11 12/* Fallback: used when --accent is guaranteed-invalid (absent, or13 explicitly set to 'initial'). Not used when --accent is present14 but nonsense -- that is the next case. */15.badge { background: var(--accent, var(--brand)); }16 17/* Invalid at computed value time. --pad exists and parsed fine;18 it just is not a valid <length>. The declaration does NOT fall19 back to the rule above -- it becomes 'unset', and padding takes20 its initial value of 0. */21.card { padding: 1rem; }22.card { --pad: banana; padding: var(--pad); } /* padding: 0 */23 24/* Registration turns that silent cliff into a rejected value with a25 defined default -- and makes the property animatable. */26@property --accent {27 syntax: "<color>";28 inherits: false; /* does not leak into nested .badge instances */29 initial-value: rebeccapurple;30}The .card pair is the one to remember. A bad custom-property value does not lose the cascade; it removes the declaration that used it, and the property falls to its initial value rather than to your previous rule.
--accent is absent
var(--accent, var(--brand)) -> guaranteed-invalid -> fallback used
background: oklch(0.6 0.18 260) OK
--pad: banana
var(--pad) -> substitutes to the token 'banana'
padding: banana -> not a valid <length>
-> INVALID AT COMPUTED VALUE TIME
-> padding: unset -> initial -> 0 BREAK
--a: var(--b); --b: var(--a)
cycle detected -> both guaranteed-invalid
every declaration using either -> unset BREAKWhat registration buys
An unregistered custom property is untyped, always inherits, and has no initial value. Registering it with @property changes all three, and adds the one thing that cannot be worked around: the browser now knows how to interpolate between two values, so the property can be transitioned and animated.
The inheritance control matters more than it first appears. A component that sets --accent on its root and contains a nested instance of itself will leak the value into the nested one, because unregistered properties inherit unconditionally. inherits: false is the only way to say "this token is mine".
- A registered property in an engine that does not support the registration behaves as unregistered — it inherits and it will not animate. That is a silent degradation, not an error.
syntax: "*"registers a property with no type checking, which gets you an initial value and inheritance control without constraining the value.- Registration is global to the document, not scoped to a selector. There is one definition of
--accentper page.
| Behaviour | Unregistered | Registered with `@property` |
|---|---|---|
| Type checking | None at parse time; failure appears as IACVT at substitution | Value must match the declared syntax or the declaration is rejected outright |
| Initial value | The guaranteed-invalid value — var() falls back or the declaration becomes unset | The declared initial-value, used whenever nothing cascaded |
| Inheritance | Always inherits; nested instances of a component inherit the outer one's value | Whatever inherits says — false gives a genuinely component-local token |
| Animation and transition | Not interpolable; a transition snaps at the halfway point rather than easing | Interpolated by type, so gradients, colours and lengths animate properly (Cheap and Expensive Animation) |
| Failure visibility | Silent. A typo produces an unset declaration and no console output | Loud at the point of assignment, which is where you can act on it |
| Cost | Nothing extra | One at-rule per token, plus per-frame interpolation while animating |
Where you declare it is an invalidation decision
This is the part that gets skipped. Because custom properties inherit, a change to one invalidates the computed style of the subtree that could depend on it. Declared on :root, that subtree is the document; declared on a component root, it is the component.
The consequence is that two identical-looking pieces of code — set a property, read it in a rule — differ in cost by however many elements are underneath the declaring element. A theme toggle doing this once is exactly right. A pointermove handler doing it on :root sixty times a second is a document-wide style recalculation per input event, and it will show up as dropped frames on any device that is not the one it was written on (Scroll and Input Latency).
Engines do track which elements actually reference a property and skip the ones that do not, so the real cost is usually better than the worst case. Design against the worst case anyway: the tracking is an optimisation you do not control and cannot see.
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Set a colour token on `:root` (theme toggle) | yes | no | yes | yes | Every element inherits it, so the invalidation set is the document — but nothing geometric changed, so layout is skipped. Once per toggle is fine. |
| Set a spacing token on `:root` | yes | yes | yes | yes | Inherited *and* consumed by geometric properties. The whole document restyles and relayouts (Style Invalidation). |
| Set a token on one component root | yes | maybe | maybe | yes | Invalidation is bounded by the subtree. Layout only if the token feeds a geometric property inside it. |
| Set a token on `:root` from `pointermove` | yes | maybe | yes | yes | The same document-wide work, per input event. This is the common way custom properties become a performance bug (The Frame Budget). |
| Transition a registered `<color>` property | yes | no | yes | yes | Interpolated per frame, and each frame restyles whatever reads it. Bounded if the property is scoped and inherits: false. |
| Transition a registered `<length>` used for `width` | yes | yes | yes | yes | Layout every frame for the duration. Animate a compositable property instead where the visual result allows (Cheap and Expensive Animation). |
| `getComputedStyle(el).getPropertyValue('--x')` | yes | no | no | no | A read that flushes pending style. Custom properties are resolved at computed-value time, so no layout is required — but it is still a synchronisation point. |
caveat Every row assumes no containment. contain: style bounds custom-property invalidation to a subtree explicitly, and content-visibility can skip the work entirely for off-screen content (content-visibility). Engines also narrow the set by tracking actual references, so measured cost is typically below the worst case shown here.
How to build it
Most important first.
- Declare a token on the narrowest element that needs it.
:rootfor genuine global design tokens; the component root for anything a component owns. This is an invalidation decision disguised as a naming decision. - Always give
var()a fallback when the property may legitimately be absent —var(--gap, 1rem). Without one, an absent property makes the whole declarationunset, which is a much larger and much more confusing failure than a wrong value. - Register anything you intend to animate, and anything where a wrong type should fail loudly.
@property --brand { syntax: "<color>"; inherits: true; initial-value: #000; }turns a silent IACVT into a rejected value with a sane default. - Use
inherits: falseon registered properties that are genuinely local — a component's internal geometry — so the value does not leak into nested instances of the same component. - Drive theming by setting a small number of properties on one element rather than by swapping stylesheets. One class on
:rootthat redefines a dozen tokens is one style invalidation and no network request (Design Systems). - For anything animating every frame, prefer writing a registered property (or a compositable property directly) over toggling classes, and confirm in a trace that layout is not being invalidated (The Frame Budget).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Custom properties are how a design system reaches user preferences without duplicating a stylesheet: one
@media (prefers-contrast: more)block redefining a handful of colour tokens changes the whole interface (Contrast, Colour and Motion). - The same applies to
prefers-reduced-motion— redefine a--transition-durationtoken to0sin one place and every component that uses it complies, instead of auditing every animation. - Colour tokens are where contrast is decided. A token pair is the right unit to test, because it is the thing reused everywhere; testing rendered components finds the same failure many times over (Accessibility Testing).
- In forced-colors mode the browser overrides colours regardless of your tokens. Use system colour keywords inside
forced-colorsblocks rather than assuming the token layer still applies. - A theme switch that changes tokens on
:rootchanges contrast for the whole page at once. Verify both themes rather than the one you develop in; a token that is readable on light and marginal on dark is a common and entirely avoidable failure.
What can go wrong
- A typo in a property name. There is no such thing as an undefined custom property error;
var(--brnad)is simply guaranteed-invalid, and without a fallback the declaration becomesunset. - IACVT surprising someone who expected a fallback to an earlier declaration.
padding: var(--pad)where--pad: reddoes not fall back to a previouspaddingrule — it becomesunset, sopaddingtakes its initial value of0. - A cycle:
--a: var(--b); --b: var(--a);. Both become guaranteed-invalid, and every declaration using either goes with them. - Setting a high-churn value on
:rootfrom apointermovehandler — a cursor-follow effect, a scroll-linked variable — and invalidating the whole document's style on every input event (Scroll and Input Latency). - The mitigation failing: scoping the property to a component and then discovering a nested instance of the same component inherits it, because unregistered custom properties always inherit. Only
@propertywithinherits: falsestops that. - Reading a custom property back with
getComputedStyle().getPropertyValue()in a hot path — a style flush per call, same as any other computed-style read (Layout Thrashing).
- Custom property values reach CSS unescaped by construction — they are token streams, not strings. A value assembled from user input and injected via
setPropertycan smuggle in aurl()that triggers a request (Content Security Policy with a restrictiveimg-srclimits, but does not remove, this). - They cannot execute script and cannot read cookies. The exposure is a request, a layout change or an overlay, not code execution (The Browser Security Model).
- Treat any custom property whose value comes from outside your application the same way you treat any other injection sink: validate against an allowlist of expected values rather than sanitising (Sanitization and Trusted HTML).
@propertywith asyntaxdescriptor is a genuine input validation mechanism — a value that does not match the declared syntax is rejected before it is ever used.
- "They are variables." They are inherited properties with a lazily-validated value. Everything surprising about them follows from that sentence.
- "They are substituted at build time." Nothing is. Substitution happens per element in the browser at computed-value time.
- "An invalid value falls back to the previous declaration." It does not. It becomes
unset, which for a non-inherited property means the initial value — usually a much more visible break. - "Using them is free." Reading them is nearly free; *changing* one high in the tree is a large invalidation (Style Invalidation).
- "I can use them in a media query." Not in the condition.
@media (min-width: var(--bp))does not work, because media queries are evaluated before computed-value time (Container Queries solves the related problem differently).
Measuring it, and what changes in the field
- The Computed pane lists custom properties alongside everything else, with the element they were inherited from — which is the fastest way to find out why a token has an unexpected value.
- Recalculate Style entries in the Performance panel show the element count affected. A theme toggle should show one large entry; a
pointermove-driven property shows one per event, which is the signature of the failure (Debugging Rendering and Jank). - Devtools shows the resolved value next to
var()in the Styles pane, and shows nothing resolvable when the property is guaranteed-invalid — which is how you spot a typo without a console error to guide you. - For registered properties, the animation inspector shows the interpolation, which is the only way to confirm that a transition on a custom property is actually running rather than snapping.
- On a large document, a
:roottoken change invalidates style for every element. On a component root, it invalidates that component. Same declaration, wildly different cost (CSS Containment can bound it further). - On a slow device, that difference is the difference between a theme toggle that feels instant and one that visibly hitches, because style recalculation is main-thread work that scales with element count.
- With a very large token set, the memory cost of inheriting hundreds of properties onto every element is measurable in a document with tens of thousands of nodes.
- In a server-rendered page, tokens in the initial HTML apply before any JavaScript runs, which makes a custom-property theme available at first paint in a way a script-driven one is not (Server-Side Rendering).
- Runtime tokens are dynamic and scoped, and they ship in every stylesheet as real values that cannot be dead-code-eliminated. Preprocessor variables disappear at build time and cannot be changed at runtime; you are choosing which capability you want.
- Inheritance makes tokens ergonomic and makes accidental leakage into nested instances the default.
@propertywithinherits: falsefixes it and requires you to enumerate every property. - A fallback in every
var()prevents the IACVT cliff and hides typos, because the fallback silently does the right thing while the intended token is misspelled. - Registering with
@propertybuys types and animation and costs a declaration per token, plus a support consideration in older engines where the registration is simply ignored and the property behaves as unregistered.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALInheritance, computed-value-time substitution, the guaranteed-invalid value and IACVT are specified in CSS Custom Properties and behave identically across Blink, Gecko and WebKit. A theme built on unregistered properties behaves the same everywhere.
- ENGINE-SPECIFICHow narrowly a change is invalidated is an implementation detail: engines track which elements actually reference a property to avoid restyling the whole inheriting subtree, and the precision of that tracking differs between Blink and Gecko. Treat "changing a root token restyles the document" as the worst case you should design against, not as a measured guarantee in any one browser.
- SPEC-EVOLVING
@propertyand the CSS Properties and Values API arrived considerably later than custom properties themselves, and the JavaScript registration form and the at-rule form have had different support timelines. In an engine that ignores the registration, the property silently behaves as unregistered — it inherits and it will not animate — so a transition that works in one browser can simply snap in another.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — a token is a named indirection with a scope, and the question of where to declare it is the same question as where to put a configuration value: as close to the thing that needs it as possible, and no closer to the root than necessary.