Styling Custom States with the :state() Selector: Specificity, :host(), and the Boundary
:state() looks like the simplest part of CustomStateSet and produces the most confusion in practice, because the same state has to be selected differently depending on which side of the shadow boundary the stylesheet lives on. A component writes :host(:state(loading)). A consumer writes my-panel:state(loading). A rule inside the shadow tree that targets a nested component writes child-el:state(loading). Write the wrong one and nothing matches, silently, with no console message and no invalid-selector warning — because all three are syntactically valid.
This deep dive belongs to Custom States & State-Driven Styling inside Styling, Theming & CSS Encapsulation. It covers the three selector forms, how :state() weighs in the cascade, and how it composes with ::part() and ::slotted().
Problem Statement: The Rule That Is Valid and Never Matches
class LoadPanel extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; padding: 1rem; border: 1px solid #cbd5f5; }
/* BUG: inside a shadow tree, a bare :state() selects a DESCENDANT
that has the state, not the host. There is no such descendant. */
:state(loading) { opacity: 0.5; }
.spinner { display: none; }
/* BUG: the host's state does not cascade into descendant selectors. */
:state(loading) .spinner { display: block; }
</style>
<div class="spinner">Loading…</div>
<slot></slot>`;
}
connectedCallback() { this.#internals.states.add('loading'); }
}
customElements.define('load-panel', LoadPanel);
load-panel really does have the state — document.querySelector('load-panel').matches(':state(loading)') returns true — and neither rule applies. The opacity never drops and the spinner never appears. Both selectors parsed fine; they simply describe elements that do not exist.
Root-Cause Analysis
CSS Selectors Level 4 defines :state(ident) as a pseudo-class matching an element whose custom state set contains that identifier. Nothing about it is special-cased for shadow trees: it is an ordinary compound-selector component, matched against whatever element the selector’s subject is.
That is the whole explanation for the first bug. A rule written in a shadow tree’s stylesheet is scoped to that shadow tree, and its subject is a node inside the tree — never the host. CSS Scoping Level 1 provides :host() precisely because the host is outside the tree the stylesheet governs; without it there is no way to name the host at all. So :state(loading) inside a shadow tree asks “which node in this tree has the state”, and the answer is none, because states live on custom elements and .spinner is a div.
The second bug follows from the same rule with a twist. :state(loading) .spinner is a descendant selector whose left compound is subject to the same scoping — it needs an in-tree element with the state. The correct form puts the host condition on the left: :host(:state(loading)) .spinner. :host() is allowed to be the left-hand side of a combinator, and the descendants it reaches are in-tree nodes, which is exactly the intent.
Specificity is the other half of the model. :host(:state(loading)) weighs as :host() plus its argument: (0,2,0) — one pseudo-class for :host, one for :state. A bare .spinner is (0,1,0), so the host-conditioned rule wins ties against a plain class. From outside, load-panel:state(loading) is (0,1,1) — one type selector, one pseudo-class. This matters because a component’s own :host rules always lose to a consumer’s rules of equal specificity: shadow-tree styles come earlier in the cascade order defined by CSS Scoping, so the consumer wins ties by tree order, not by specificity. That is intentional, and it is what makes :state() a genuine theming hook rather than a sealed internal.
:state() takes a single identifier, not a selector list. :state(loading, error) is invalid and, per CSS error handling, invalidates the entire rule it appears in — including any declarations you expected to apply unconditionally. Use :is() for alternation: :host(:is(:state(loading), :state(stale))). The failure is silent, so a rule that "stopped working after I added a second state" is almost always this.
There is one further composition rule worth internalising. :state() can qualify a ::part(), but only on the host side: load-panel:state(loading)::part(body) is valid and styles the exposed part while the host is loading. The reverse — putting :state() after ::part() — is limited to the small set of pseudo-classes allowed on parts, and is covered in combining custom states with part selectors.
Production-Safe Pattern
class LoadPanel extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; padding: 1rem; border: 1px solid #cbd5f5; border-radius: 10px; }
/* Host condition — the state belongs to the host, so :host() names it. */
:host(:state(loading)) { opacity: 0.6; cursor: progress; }
:host(:state(error)) { border-color: #c62828; }
/* Host condition driving a descendant: :host() on the left of a combinator. */
.spinner { display: none; }
:host(:state(loading)) .spinner { display: block; }
.retry { display: none; }
:host(:state(error)) .retry { display: inline-block; }
/* Alternation needs :is() — :state() takes ONE identifier. */
:host(:is(:state(loading), :state(stale))) .timestamp { opacity: 0.5; }
/* Negation composes normally. */
:host(:not(:state(loading))) .content { transition: opacity 160ms ease; }
/* A NESTED custom element inside this tree uses the bare form,
because that element really is a descendant of this tree. */
inner-badge:state(pinned) { font-weight: 700; }
</style>
<div class="spinner" role="status" aria-live="polite">Loading…</div>
<button class="retry" type="button">Retry</button>
<span class="timestamp"><slot name="time"></slot></span>
<div class="content"><slot></slot></div>
<inner-badge></inner-badge>`;
}
setPhase(phase) {
const { states } = this.#internals;
for (const name of ['loading', 'error', 'stale']) {
if (name !== phase) states.delete(name);
}
if (phase) states.add(phase);
}
}
customElements.define('load-panel', LoadPanel);
/* Consumer stylesheet: tag-qualified, no :host() involved. */
load-panel:state(loading) { --panel-shadow: none; }
load-panel:state(error) { outline: 2px solid #c62828; }
/* Reaching an exposed part while the host is in a state. */
load-panel:state(loading)::part(body) { filter: grayscale(1); }
The rule to remember is a single sentence: use :host(:state(x)) when the state is on your own host, and the bare :state(x) only when the state is on a descendant custom element. Every other form follows from that.
Verification
const panel = document.querySelector('load-panel');
panel.setPhase('loading');
// The state is genuinely set — this passes even when the CSS is wrong.
console.assert(panel.matches(':state(loading)'), 'state present on the host');
// The styling consequence is the real assertion.
const spinner = panel.shadowRoot.querySelector('.spinner');
console.assert(getComputedStyle(spinner).display === 'block', 'host-conditioned rule applied');
console.assert(parseFloat(getComputedStyle(panel).opacity) < 1, ':host(:state()) applied');
// Mutual exclusivity holds after a transition.
panel.setPhase('error');
console.assert(!panel.matches(':state(loading)'), 'previous phase cleared');
console.assert(panel.matches(':state(error)'), 'new phase set');
// The invalid-argument trap: this selector is invalid and matches nothing.
console.assert(
(() => { try { return document.querySelector('load-panel:state(loading, error)') === null; }
catch { return true; } })(),
':state() takes a single identifier'
);
The DevTools workflow that finds these fastest is the Styles pane’s :hov panel — it lists forced pseudo-states — combined with the Computed pane’s “matched rules” list on the host. If matches(':state(loading)') is true in the console but the host shows no matching rule, the selector form is wrong. Chromium also renders custom states in the Elements panel next to the element, so you can confirm the set without running any script.
When to Use vs When to Avoid
| Where the stylesheet lives | Where the state lives | Selector |
|---|---|---|
| Component’s shadow tree | Its own host | :host(:state(x)) |
| Component’s shadow tree | Its own host, styling a descendant | :host(:state(x)) .child |
| Component’s shadow tree | A nested custom element inside the tree | nested-el:state(x) |
| Consumer document | The component’s host | my-el:state(x) |
| Consumer document | The component’s host, styling an exposed part | my-el:state(x)::part(body) |
| Either | Several states, any of which qualifies | wrap in :is() |
| Either | Absence of a state | wrap in :not() |
One habit makes the whole table unnecessary in review: keep every state selector in the component’s stylesheet anchored to :host(), and treat any bare :state() in that file as a defect unless it is immediately followed by a custom element tag name. A single lint rule expressing that catches the mistake before it reaches a browser, and it is far cheaper than discovering the silent non-match during visual QA.
Two entries are commonly got wrong in review. Row three is the only case where the bare form is correct inside a shadow tree, and it is rare — it requires the component to contain another custom element that publishes states. And row five is the only supported way for a consumer to style the component’s interior conditionally, which is why exposing parts and exposing states are complementary decisions rather than alternatives.
Frequently Asked Questions
Why does :state(loading) not work inside my component's stylesheet?
Because a shadow-tree rule’s subject is a node inside that tree, and the state lives on the host, which is outside it. Name the host explicitly with :host(:state(loading)).
Can :state() take more than one identifier?
No. It accepts exactly one, and a comma-separated argument invalidates the whole rule silently. Use :is(:state(a), :state(b)) when any of several states should match.
Does a consumer rule beat the component's own :host rule?
At equal specificity, yes — shadow-tree styles sort before document styles in the cascade, so tree order decides and the consumer wins. This is deliberate and is what makes states a usable theming hook.
Can I style a slotted element based on the host's state?
Yes: :host(:state(loading)) ::slotted(p) works, because :host() is on the left of the combinator. The usual ::slotted() reach limits still apply — only top-level assigned nodes match.
Related
- Custom States & State-Driven Styling — the parent topic on
CustomStateSet. - Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Combining Custom States with Part Selectors — reaching the interior conditionally.
- Migrating from Dashed-Ident Custom States — the older selector syntax.
- CSS Scoping in Shadow DOM — why tree order decides ties across the boundary.