How does inheritance and cascading work with CSS variables, and how can you scope a variable to a component?

Intermediate12 min interview
Skills tested:
Explaining that var() resolves on the using element, not the declaring oneUsing a subtree override to retheme a componentExplaining the invalid-at-computed-value-time behaviourUsing var() fallbacks and layered token namesKnowing how @property inherits: false changes scoping

Advertisement

🧩 Scenario

Scoping is what turns custom properties from a colour-constant mechanism into a component API. A button that reads var(--btn-bg) can be retinted by any ancestor setting --btn-bg, with no modifier class, no new selector, and no knowledge of the button internals. Getting the scoping model right is the difference between a design system whose components can be adapted in place and one where every variation needs a new class shipped from the library.

Architecture Walkthrough

Custom Properties Inherit Like Any Inherited Property

A custom property behaves like a normal inherited CSS property. Declared on an element, it is available to that element and every descendant, until something redeclares it. Declared on :root, it is available document-wide, which is why global tokens live there.

Because it inherits, redeclaring it anywhere in the tree changes it for that subtree only. That is the mechanism behind almost every practical use: one declaration on a wrapper retints everything inside it without any of those inner rules being aware.

var() Resolves on the Using Element

This is the detail that makes the model coherent, and it is frequently misunderstood. Custom properties are resolved at computed-value time, per element. When an element's gap: var(--gap) is computed, the browser looks up the value of --gap as it computes on that element, following inheritance.

So a rule .child { gap: var(--gap) } produces different results for different .child elements depending on which ancestor's --gap they inherit. The rule is written once; the resolution happens per element. This is also why a declaration such as --gap: 2rem on .parent affects descendants but does not itself style .parent: declaring a custom property does nothing visible on its own, it only makes a value available.

A subtle consequence is that a custom property whose value references another custom property is resolved through the inherited chain at the point of use. --shadow: 0 2px 4px var(--shadow-color) picks up whichever --shadow-color is in scope on the element using --shadow, not whichever was in scope where --shadow was declared.

Invalid at Computed-Value Time

The failure behaviour is unusual and worth stating precisely, because the intuitive guess is wrong.

If a var() reference points at a property that is not defined, and no fallback is given, the declaration is invalid at computed-value time. If the property is defined but holds a value that is invalid for the property using it, the same thing happens. In both cases the property does not fall back to a previous declaration in the cascade; it computes to its inherited value for inherited properties, or its initial value for non-inherited ones, which is equivalent to unset.

That means a typo in a token name does not leave the previous colour in place; it produces the inherited colour, which may look nothing like either. var(--x, fallback) guards against the undefined case, but note the limitation: the fallback applies only when --x is undefined, not when --x is defined with a value that turns out to be invalid for the target property.

Scoping Patterns

Subtree override. The simplest and most useful pattern. Declare the token where you want the change to apply and let inheritance carry it.

.panel--danger { --accent: #dc2626; }

Component-local tokens. Declare the token on the component root so it is scoped to the component but overridable from outside. The component reads its own token; a consumer sets it on the component or any ancestor.

.btn {
  --btn-bg: #3b82f6;
  background: var(--btn-bg);
}
.toolbar .btn { --btn-bg: transparent; }

This is the pattern that turns a component into something with an API. The consumer never touches background, so the component keeps control of how the token is used while allowing what it resolves to be changed.

Two-layer tokens. Separate global primitives from semantic component tokens, with the component token defaulting to a primitive:

:root { --color-blue-500: #3b82f6; }
.btn { --btn-bg: var(--btn-bg-override, var(--color-blue-500)); }

This gives consumers a narrow, named surface to override while the primitives stay stable, and it is roughly how mature design systems structure their token layers.

Preventing inheritance. If a token should apply only to the element declaring it, register it with @property and inherits: false. Otherwise, resetting it explicitly on the subtree is the manual alternative.

Real encapsulation. Custom properties pierce shadow DOM boundaries by design, which is a feature for theming: a web component can accept --btn-bg from the outside even though its internals are encapsulated. If you need a value genuinely private to a component, the tools are @property with inherits: false or simply not documenting the token.

Specificity Applies Normally

Custom property declarations are resolved by the ordinary cascade: origin and importance, then layers, then inline styles, then specificity, then source order. :root { --x: a } versus .theme { --x: b } on the same element is decided by specificity like any other conflict, and inline style="--x: c" beats both. There is nothing special about custom properties here, which is convenient because the existing mental model transfers directly.


Key Code Explained

/* Global tokens: declared once, inherited everywhere */
:root {
  --accent: #3b82f6;
  --space: 1rem;
  --radius: 8px;
}

/* Subtree override: one declaration retints everything inside */
.panel--danger {
  --accent: #dc2626;
}
/* Every var(--accent) inside .panel--danger now resolves to red,
   without any of those inner rules knowing about the override. */

/* var() resolves on the USING element, not the declaring one */
.parent {
  --gap: 2rem; /* declares only; does not style .parent itself */
}
.child {
  gap: var(--gap); /* resolved per .child, via inheritance */
}

/* COMPONENT-LOCAL TOKEN: scoped, with a default, overridable from outside */
.btn {
  --btn-bg: #3b82f6;
  --btn-fg: #fff;
  background: var(--btn-bg);
  color: var(--btn-fg);
  border-radius: var(--radius);
}
/* A consumer retints without touching background at all */
.toolbar .btn {
  --btn-bg: transparent;
  --btn-fg: var(--accent);
}

/* TWO-LAYER TOKENS: stable primitives, narrow override surface */
:root {
  --color-blue-500: #3b82f6;
  --color-red-600: #dc2626;
}
.btn--semantic {
  --btn-bg: var(--btn-bg-override, var(--color-blue-500));
  background: var(--btn-bg);
}

/* INVALID AT COMPUTED-VALUE TIME */
.broken {
  color: red;
  color: var(--nope); /* undefined -> the declaration is invalid ->
                         color computes to its INHERITED value, NOT red */
}
.guarded {
  color: var(--nope, red); /* fallback applies when UNDEFINED */
}
:root { --bad: banana; }
.still-broken {
  color: var(--bad, red); /* --bad IS defined but invalid for color,
                             so the fallback does NOT apply */
}

/* Prevent inheritance entirely */
@property --local-only {
  syntax: '<length>';
  initial-value: 0px;
  inherits: false;
}

/* Specificity applies normally to custom property declarations */
:root  { --x: a; }          /* (0,0,0) */
.theme { --x: b; }          /* (0,1,0) wins on an element matching both */
/* style="--x: c" beats both */

The .btn and .toolbar .btn pair is the pattern worth carrying into an interview, because it is what "scoping a variable to a component" actually means in practice. The button owns background: var(--btn-bg) and never gives that up; the consumer supplies a different value for the token. The component keeps control of how the value is applied while the consumer controls what it is, which is a genuine interface rather than an override.

The .broken and .still-broken block is the behaviour to be able to state precisely. Most people assume a bad var() leaves the previous declaration in place, and it does not: the property computes to its inherited or initial value. The second case is subtler still, since a fallback that looks like a safety net does nothing when the property is defined but holds a value the target property cannot accept.


Tradeoffs

Scoping approachWhere declaredReachBest for
Global token:rootWhole documentPalette, spacing scale, radii
Subtree overrideA wrapper elementThat subtreeSections, themed panels, states
Component-local tokenThe component rootThe component, overridable from outsideComponent APIs
Two-layer token:root primitives + component semanticNarrow override surfaceDesign systems
@property inherits: falseAnywhereThe declaring element onlyGenuinely local values

What Interviewers Actually Check

  • Whether you know var() resolves on the using element, not the declaring one
  • Whether you can use a subtree override to retheme without new classes
  • Whether you can state the invalid-at-computed-value-time behaviour accurately
  • Whether you can design a component token that a consumer can override
  • Whether you know the fallback only covers the undefined case

Follow-Up Questions

  1. Why does the var() fallback not apply when the property is defined with an invalid value?
  2. How do custom properties cross shadow DOM boundaries, and why was that designed in rather than blocked?
  3. What does registering a property with @property change about inheritance, validation, and animation?
  4. How would you structure token layers for a design system consumed by teams with their own brand colours?
  5. Are there performance implications to declaring a large number of custom properties on :root, and where would the cost show up?

Common Candidate Mistakes

  • Believing var() resolves where the custom property was declared, which makes per-element inheritance behaviour look inconsistent
  • Assuming a broken var() reference leaves the previously declared value in place, when the property instead computes to its inherited or initial value
  • Declaring every token on :root and then having no scoping mechanism left when a section or component needs a different value
  • Treating a var() fallback as a general safety net, when it applies only if the property is undefined and not if it holds a value invalid for the target property
  • Shipping components that expose no tokens, forcing consumers to override concrete properties like background with higher-specificity selectors instead of setting a documented variable

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain where var() resolves and why that is what enables subtree theming?
  • Can you write a component that exposes overridable tokens with sensible defaults?
  • Can you explain what happens when a var() substitution is invalid?
  • Can you use a two-layer token naming scheme and say what each layer is for?
  • Can you prevent a custom property from inheriting?

Summary

Custom properties are inherited CSS properties, so declaring one on an element makes it available to that element and every descendant until something redeclares it. Crucially, var() is resolved at computed-value time on the element using it, not the element declaring it, which is why a single rule such as gap: var(--gap) produces different results across the tree and why redeclaring a token on a wrapper retints everything inside it without those inner rules being aware.

The failure behaviour needs stating precisely: a var() reference to an undefined property, or to one holding a value invalid for the target property, makes the declaration invalid at computed-value time. The property then computes to its inherited value for inherited properties or its initial value otherwise, rather than falling back to a previous declaration. The var(--x, fallback) form guards only the undefined case, not the defined-but-invalid one.

Scoping runs from broad to narrow: global tokens on :root, subtree overrides on a wrapper, and component-local tokens declared on the component root so the component controls how a value is applied while consumers control what it resolves to. A two-layer scheme, with stable primitives on :root and semantic component tokens defaulting to them, is what mature design systems use, and @property with inherits: false is the tool when a value must not propagate. Throughout, custom property declarations are resolved by the ordinary cascade, so the existing specificity model transfers unchanged.

Frequently Asked Questions

Where does var() resolve, on the element that declares it or the one that uses it?

On the element that uses it. Custom properties are resolved at computed-value time per element, which is why the same rule can produce different results on different elements.

How do you stop a custom property from inheriting?

Register it with @property and set inherits: false, or explicitly reset it on the subtree with a value or the initial keyword.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement