A component's internals live in a shadow root that page CSS cannot select. How do CSS custom properties let the page theme it anyway, and what are the limits of that contract?
answer
- custom properties are inherited properties
- opt-in on the component's side
- fallback versus :host default
- declaration beats inherited value
- token names become public API
basics
~20 sCustom properties are inherited properties, so a value set on the host or any ancestor flows into the shadow tree. The component author must opt in by consuming it in a var() call — only the hooks they wired are themeable, and the names become public API.
solid answer
~50 sCustom properties (`--*`) inherit like `color` does, and inheritance crosses the shadow boundary, so `my-button { --btn-bg: crimson }` in the page reaches every `var(--btn-bg)` inside that component's shadow tree. The contract is opt-in from the inside: the author decides which declarations read a token, and everything else stays private. Two limits matter. First, it is **value-level only** — a consumer can change a color or a radius, never the internal markup, never a property the author did not wire. Second, **where you put the default changes who can win**: writing `:host { --btn-bg: #333 }` puts a real declaration on the host, which beats any value merely inherited from `body`, so page-wide tokens silently fail to reach you. Putting the default in the `var()` fallback — `background: var(--btn-bg, #333)` — leaves inheritance free to supply it. And the token names are now API: renaming one is a breaking change.
code
css · 13 lines:host {
display: inline-block;
}
button {
/* default lives in the fallback, so page-wide tokens still reach us */
background: var(--btn-bg, var(--color-surface, #333));
color: var(--btn-fg, #fff);
border-radius: var(--btn-radius, 2px);
padding: var(--btn-padding, 0.5em 1em);
border: none;
font: inherit;
}go deeper
Know that a page themes a shadow-DOM component by setting --something on the element and that the component must read it with var(). Setting a token the component does not use simply does nothing.
Explain that custom properties are inherited, so they cross where selectors cannot, and that the component opts in per declaration. Be ready to explain why a :host default blocks a page-wide token while a var() fallback does not.
Show judgment about surface size: which knobs you expose, how you alias component tokens to design-system tokens, and how you debug a consumer report of "theming does nothing" by checking where the default was declared.
Own token naming and lifecycle across a library — namespacing, how many knobs per component is too many, and how you version or deprecate a token given the platform gives you no warning when a stale name stops matching.
## Why custom properties cross at all A custom property is just a property whose name starts with `--`, and the spec defines it as **inherited**. Inheritance is computed over the flattened tree, which stitches shadow trees into the document, so a custom property declared anywhere up the chain — `:root`, `body`, or the host element itself — has a computed value on nodes inside the shadow root. Nothing had to be selected. That is the whole trick: encapsulation blocks selector matching, and custom properties are a channel that does not need selector matching. ```css /* page */ my-button { --btn-bg: crimson; } /* inside the shadow root */ button { background: var(--btn-bg, #333); } ``` The page never selected the inner `<button>`. It set a value on the host; the value inherited down; `var()` substituted it. ## The contract is opt-in from the inside Substitution only happens where the component author wrote a `var()`. A page can declare `--anything-at-all` and it will inherit into the shadow tree harmlessly, doing nothing. This is what makes custom properties the *safe* theming primitive: the surface is exactly the set of tokens the author chose to honour, and everything else — the internal element names, the layout, the nesting — stays private and refactorable. That is also the limit. Tokens are **value-level**. A consumer can change a background, a radius, a gap, a font size. They cannot reorder the internals, add a pseudo-element, change `display`, or style a state the author did not expose, unless the author wired a token for it. If a consumer's need is unbounded, tokens are the wrong tool and a `part` is the right one. ## Where the default lives decides who wins This is the detail interviews probe, because it produces a genuinely confusing bug. There are two places to put a default value: ```css /* option A: declare on the host */ :host { --btn-bg: #333; } button { background: var(--btn-bg); } /* option B: fallback in the var() */ button { background: var(--btn-bg, #333); } ``` They behave differently under inheritance. In option A, `:host` puts a **cascaded declaration on the host element**. A declaration always beats a value that merely arrives by inheritance — inheritance is the fallback when nothing was declared. So a page-wide `body { --btn-bg: crimson }` never reaches the component: the host declares its own value and the inherited one is discarded before it ever gets there. The component looks stubbornly un-themeable, and the consumer's devtools show `--btn-bg: crimson` on `body` and `#333` on the host, which reads as a bug in the browser until you know the rule. In option B nothing is declared on the host, so the inherited `crimson` survives and `var()` uses it; `#333` applies only when no ancestor supplied a value. **Option B is the default you want for tokens meant to be set page-wide.** Note what does still work in option A: a page rule that targets the host directly — `my-button { --btn-bg: crimson }` — declares on the same element as `:host`. When two declarations from different trees collide on one element, the shadow cascade order makes the **outer tree win** for normal declarations, so the page's value takes effect. That is why some consumers report "it works when I target the element, not when I set it on `:root`" — a precise symptom of an option-A default. ## Practical patterns **Alias to a design-system token.** Give the component its own namespaced knob but default it to the system token: `background: var(--btn-bg, var(--color-surface, #333));`. Consumers theming globally set `--color-surface`; consumers theming one component set `--btn-bg`. **Token per visual decision, not per CSS declaration.** `--btn-bg` used by three rules is a contract; three separate tokens for the same visual concept is churn. **Custom properties reach into slotted content too.** Light-DOM children inherit from where the slot sits in the flattened tree, so a token declared in the shadow tree on the slot's ancestor reaches nodes the page authored — a useful way to hand values *outward*. **They are also runtime-settable.** `el.style.setProperty('--btn-bg', 'crimson')` themes one instance from script, and reading back with `getComputedStyle(el).getPropertyValue('--btn-bg')` gives the resolved value. ## What it costs you Token names are public API the moment someone ships against them. There is no reflection — a page cannot ask a component which tokens it supports, so the list lives in documentation and nowhere else, and a rename is a silent breaking change: the old name simply stops matching a `var()` and the consumer gets your default back with no error. Treat the token list like an exported function signature, keep it small, and prefer adding a token over broadening one's meaning.
- A page sets `body { --accent: crimson }` but the component still renders navy. Its shadow CSS has `:host { --accent: navy }` and uses `var(--accent)`. Why?`:host` puts an actual declaration on the host element, and a declaration always beats a value that only arrives by inheritance — so `crimson` is discarded at the host. Move the default into the substitution instead: `var(--accent, navy)`. As a workaround the consumer can target the host directly (`my-el { --accent: crimson }`), because outer-tree normal declarations beat shadow-tree ones on the same element.
- Can a consumer discover which custom properties a component supports?Not from the platform. There is no reflection over which tokens a shadow tree consumes, and setting an unsupported one fails silently with no warning. The supported list is documentation only, which is why teams namespace tokens per component and treat renames as breaking changes.
- Do custom properties declared inside a shadow root reach slotted light-DOM content?Yes. Slotted nodes inherit through the flattened tree, from wherever the `<slot>` sits in the shadow tree — not from their DOM parent in the page. So a token declared on an element that wraps the slot reaches the consumer's own markup, which is a clean way to pass values outward for slotted children to consume.
Think of tokens as labelled sockets on a sealed appliance: you can plug a different bulb into a socket the manufacturer fitted, but you cannot open the case and rewire it.
saying these in an interview costs you the question
- Setting any custom property themes any internal element
- Custom properties need :host to cross the boundary
- Defaults belong on :host, always
- A page can enumerate a component's supported tokens
- Renaming a token is safe, it's just CSS