CSSGENERALENGINE-SPECIFICSPEC-EVOLVING

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.

The question

What is a CSS custom property actually — a variable, a property, or something the cascade participates in — and what does changing one cost?

The user intent

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.

The obvious build

They are variables. The build tool substitutes them, like a preprocessor variable, and using more of them is free.

Why it breaks

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.

How it breaks in a real browser
  • 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 :root invalidates 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: 1rem and --gap: banana are 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • They are properties. --brand is 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 the var(--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: initial deliberately restores that state.
  • `@property` registers a type. Declaring syntax, inherits and initial-value gives 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) and getComputedStyle(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 :root is 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 :root when 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.

Scoping, fallbacks, and the cliff
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 .compact
7 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, or
13 explicitly set to 'initial'). Not used when --accent is present
14 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 fall
19 back to the rule above -- it becomes 'unset', and padding takes
20 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 a
25 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                            BREAK

What 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 --accent per page.
BehaviourUnregisteredRegistered with `@property`
Type checkingNone at parse time; failure appears as IACVT at substitutionValue must match the declared syntax or the declaration is rejected outright
Initial valueThe guaranteed-invalid value — var() falls back or the declaration becomes unsetThe declared initial-value, used whenever nothing cascaded
InheritanceAlways inherits; nested instances of a component inherit the outer one's valueWhatever inherits says — false gives a genuinely component-local token
Animation and transitionNot interpolable; a transition snaps at the halfway point rather than easingInterpolated by type, so gradients, colours and lengths animate properly (Cheap and Expensive Animation)
Failure visibilitySilent. A typo produces an unset declaration and no console outputLoud at the point of assignment, which is where you can act on it
CostNothing extraOne 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.

Changing a custom property
ChangestylelayoutpaintcompositeWhy
Set a colour token on `:root` (theme toggle)yesnoyesyesEvery 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`yesyesyesyesInherited *and* consumed by geometric properties. The whole document restyles and relayouts (Style Invalidation).
Set a token on one component rootyesmaybemaybeyesInvalidation is bounded by the subtree. Layout only if the token feeds a geometric property inside it.
Set a token on `:root` from `pointermove`yesmaybeyesyesThe 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>` propertyyesnoyesyesInterpolated 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`yesyesyesyesLayout every frame for the duration. Animate a compositable property instead where the visual result allows (Cheap and Expensive Animation).
`getComputedStyle(el).getPropertyValue('--x')`yesnononoA 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. :root for 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 declaration unset, 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: false on 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 :root that 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-duration token to 0s in 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-colors blocks rather than assuming the token layer still applies.
  • A theme switch that changes tokens on :root changes 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

Failure modes
  • 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 becomes unset.
  • IACVT surprising someone who expected a fallback to an earlier declaration. padding: var(--pad) where --pad: red does not fall back to a previous padding rule — it becomes unset, so padding takes its initial value of 0.
  • 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 :root from a pointermove handler — 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 @property with inherits: false stops 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).
Security
  • Custom property values reach CSS unescaped by construction — they are token streams, not strings. A value assembled from user input and injected via setProperty can smuggle in a url() that triggers a request (Content Security Policy with a restrictive img-src limits, 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).
  • @property with a syntax descriptor is a genuine input validation mechanism — a value that does not match the declared syntax is rejected before it is ever used.
Misreads
  • "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

How you would see this
  • 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.
Slow device, slow network, large data, old tab
  • On a large document, a :root token 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).
What this costs
  • 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. @property with inherits: false fixes 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 @property buys 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@property and 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.

Domains that do not exist yet
  • 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.