Edit (2026.08.24): Got feedback from the Web Components Community Group that I was mixing up inheritance, specificity, and the cascade and that * might be a better selector than :defined. I agree with both of those perspectives and I’ve updated this post.
Stuart Robson’s post Solving CSS @layer Ordering in Design Systems with Design Token Metadata, a response to Chris Coyier’s Thinking Horizontally in CSS @layer, came to me at a serendipitous time. You know I love a good blog-and-response, but these posts on the topic of lowering the specificity of component-level tokens and scaling that out was a problem I was having with some of my web components.
Their @layer trick didn’t work for me, but here’s what did…
The local component variable problem
Let’s say we have a global stylesheet with our design system tokens.
/* theme.css */
:root {
--system-bg: #00f1;
--system-fg: #000;
--system-font: Inter, sans-serif;
}
And then we set up our web component with some local component variables that use design system tokens as defaults.
<ui-card>
<template shadowrootmode="open">
<style>
:host {
/* Local component variables */
--card-bg: var(--system-bg);
--card-fg: var(--system-fg);
--card-font: var(--system-font);
/* Apply component styles */
background: var(--card-bg);
color: var(--card-fg);
font-family: var(--card-font);
button {
font-family: var(--card-font);
}
}
</style>
<slot></slot>
</template>
Hello world
</ui-card>
This works great… Except… I sort of expected that if we added some of those component tokens to the stylesheet we could restyle the card.
/* theme.css - This doesn't work 😭 */
:root {
/* ...system tokens go here... */
--card-bg: #f001;
--card-fg: #f00;
--card-font: fantasy;
}
:host has the same specificity as :root (0,1,0), but because :host declares the variables directly on the element, it replaces the inherited values. This confused even me, a web component front-end master, because there’s a weird styling relationship in how :host overrides :root, but classes or an element-selector the Light DOM overrides :host. But Bramus from the Chrome team explained it to me as: If you redeclared the variables in a normal div, it wouldn’t be surprising that the values didn’t inherit. Ok, that tracks.
The canonical way to solve this –of course– is to pull out a bazooka and point it directly at the element:
/* theme.css - Works, but... */
ui-card {
--card-font: fantasy;
}
This works and I often recommend direct styling the element as the primary way to customize web components from the outside.
But a system is different than laser-guided fixes. Themes should be low-specificity by design. Using an element as a selector is one of the strongest selectors in our CSS toolbox has low specificity but is super specific to that element. If we ever want to change our mind, –say updating the token value in :host(:hover)–, we’re on a one-way train to !important town. To me, it’s a problem if our CSS and DOM are super-glued together. It’s less ideal compared to a flat list of element-agnostic tokens.
What if other card-like elements use those tokens? Uh-oh, gotta update the global stylesheet. What if elements in the Light DOM also want to use those tokens? More work. We need a “Set it and forget it” way to handle themes. Also pinning tokens to the custom-element might not always work in nested component scenarios, but that’s a secondary issue.
There’s a handful of other approaches (see below) to this problem and they all have trade-offs. I was hoping the @layer solution would help, but @layer { :host {} } inside a shadowRoot is still more powerful local than :root. Drat!
But is there a low-specificity way to win the specificity war with override :host?
A refined solution in :defined *
:definedAn earlier version of this post recommended :defined as a low-specificity (0,1,0) way to override :host from the outside (see below). I like the visual/syntactic similarities there… but we can go even lower on the specificity power-ranking chart by using * (0,0,0,0).
/* theme.css */
:root {
--system-bg: #00f1;
--system-fg: #000;
--system-font: Inter, sans-serif;
}
* {
--card-bg: #f001;
--card-fg: #f00;
--card-font: fantasy;
}
I hesitate to recommend * for a three reasons:
- One time Paul Irish said it had bad performance. This was probably a micro-optimization, but something a design system should think about when making decisions for everyone.
- It broadly applies to every element, but so does
:defined(essentially). - Tailwind’s Preflight1 has
* { padding: 0; margin: 0; }and overrides padding and margin styles on:host, I dislike that side-effects from*.
But –as it happens– that side-effect is the exact behavior we want to make our theme variables work. We abuse * to apply our tokens on the Light DOM host element, which beats the interior Shadow DOM :host.
Live demo
See the Pen shadow-piercing local component variables with * selector by Dave Rupert (@davatron5000) on CodePen.
Can future CSS help us?
Bramus told me about one potential future CSS feature that might help this exact situation controlling inheritance inside the shadow root :host, the already spec’d out was the inherit() function.
/* ui-card.css */
:host {
--card-bg: inherit(--card-bg, var(--system-bg));
background: var(--card-bg);
}
I like that. That could work. Let’s do that? Ok. We all agree. Ship it. I’d also love a way to target my custom-elements directly:
/* theme.css */
/* Select only custom-elements */
:custom-elements {
--card-font: fantasy;
...
}
/* OR */
/* Glob select elements based on an custom-element or class prefix */
shadowdom-*,
.lightdom-* {
--card-font: fantasy;
...
}
Either way, that’s the end of my post. If you need your Light DOM and Shadow DOM components to allow for fallbacks, try the original :defined technique below that has a bit more powerful selector. And if you’re curious about other alternatives considered to handle component-level variables, keep reading!
Read original :defined technique
/* theme.css */
:root {
--system-bg: #00f1;
--system-fg: #000;
--system-font: Inter, sans-serif;
}
:defined {
--card-bg: #f001;
--card-fg: #f00;
--card-font: fantasy;
}
When you put <ui-card> on the page, it’s an Unknown Custom Element2. Because HTML is chill, it treats the unknown custom element as a HTMLUnknownElement<span> with a gigantic typo. The :defined pseudo-state triggers on customElement.define() (or when you use Declarative Shadow DOM) and with that small change we’re able to pierce the shadow boundary and win the specificity war because :defined apply styles to the host-element from the outside, which is more powerful than :host from the inside.
But… You know what else gets :defined? All native HTML elements! div:defined, video:defined, and every native element your browser supports also works with :defined.
That gives us some portability that the ui-card selector didn’t. If we need to re-use these tokens inside a Light DOM CSS class, we can also support that with :defined.
.ad-unit-block {
/* Setup variable fallbacks */
--card-bg: var(--system-bg);
--card-fg: var(--system-fg);
--card-font: var(--system-font);
/* Apply component styles */
background: var(--card-bg);
color: var(--card-fg);
font-family: var(--card-font);
}
And that’s pretty handy if your default styles and system overrides need to go more places.
Full walkthru demo
See the Pen shadow-piercing local component variables by Dave Rupert (@davatron5000) on CodePen.
Alternatives considered
Below are a handful of different workarounds for local-component variables, each with trade-offs.
Make all root overrides !important
/* theme.css - Doesn't work, also too heavy handed */
:root {
--card-font: fantasy!important;
}
This works doesn’t work, but should be the nuclear option for specificity battles. Also makes it more difficult to override later.
Shipping all variables in the theme
/* theme.css */
:root {
/* system theme */
--system-bg: #00f1;
--system-fg: #000;
--system-font: Inter, sans-serif;
/* custom overrides */
--card-bg: #f001;
--card-fg: var(--system-fg);
--card-font: fantasy;
}
This works too. You’ll need to change your <ui-card> styles to directly access background: var(--card-bg). The problem here is that all variables are mandatory and must ship in the theme. Even if a product only uses or customizes just one component, they have to ship all the tokens for every component. And now your theme CSS will be ever-growing at the rate of num_options * num_components.
Optional variable fallback chains in shadowRoot
/* theme.css - Works, but can be confusing to CSS learners */
:host {
font-family: var(--card-font, var(--system-font));
}
I’ve been down this path. It works well and the first variable in the fallback chain becomes totally optional, meaning we can ship less tokens in the production theme. Fallback chains (a ternary, basically) and Aliases (a DOM walker) have dramatically different performance profiles when it comes to style calculation costs, so you may want to consider this… but that’s for another post.
In practice though, I find the beauty of the CSS cascade gets lost on a lot of six-figure engineers (waves generally in the direction of Hacker News and popular CSS frameworks).
And this also works less good if you’re going to re-use a local variable token… which leads us to…
Private variables with fallback chains in shadowRoot
/* theme.css - Works, but too noisy */
:host {
--_card-font: var(--card-font, var(--system-font));
font-family: var(--_card-font);
button {
font-family: var(--_card-font);
}
}
This is the fully exploded version of the previous pattern. We can re-use --_card-font on other elements inside our component. You can do this but it begins to explode the surface area of the number of tokens we’re managing inside a component. Fallbacks were confusing enough, now we’re doing fallbacks with aliases. Uf.
-
LLMs love to copy Tailwind’s Preflight. ↩
-
Thanks to Danny Engelman for the correction. ↩