Combining Custom States with Part Selectors: Reaching the Interior Conditionally
A component that exposes both a state vocabulary and a set of parts gives consumers something neither feature provides alone: the ability to restyle a specific internal element while the component is in a specific condition. my-panel:state(loading)::part(body) says “grey out the body region, but only while it is loading” — one selector, no JavaScript, no attribute the component has to reflect.
The composition works, and it is asymmetric in a way that trips people up. :state() may qualify the host before ::part(); it may not qualify the part after it, except in a small allowed set. Understanding which side of the pseudo-element each condition belongs on is most of what there is to learn. This deep dive belongs to Custom States & State-Driven Styling inside Styling, Theming & CSS Encapsulation.
Problem Statement: The Selector That Reads Right and Matches Nothing
class DataPanel extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; border: 1px solid #cbd5f5; border-radius: 10px; }
[part~="header"] { padding: 0.75rem 1rem; font-weight: 700; }
[part~="body"] { padding: 1rem; }
</style>
<div part="header"><slot name="title"></slot></div>
<div part="body"><slot></slot></div>`;
}
setLoading(on) {
if (on) this.#internals.states.add('loading');
else this.#internals.states.delete('loading');
}
}
customElements.define('data-panel', DataPanel);
/* Consumer stylesheet. One of these works; the other silently does not. */
data-panel::part(body):state(loading) { filter: grayscale(1); } /* nothing happens */
data-panel:state(loading)::part(body) { filter: grayscale(1); } /* correct */
Both selectors parse. The first one describes a part that itself carries the state — and parts are ordinary div elements in the shadow tree, which have no custom state set — so it never matches. The consumer sees no styling, no console message, and no obvious difference between the two lines.
Root-Cause Analysis
CSS Shadow Parts defines ::part() as a pseudo-element that selects an element inside a shadow tree which has been explicitly exposed by a part attribute. Like every pseudo-element, it changes what the selector’s subject is: everything to its left describes the originating element (the host), everything to its right describes the pseudo-element itself.
So in data-panel:state(loading)::part(body), the :state(loading) is a condition on data-panel — the host, which is the element that owns the custom state set. That is exactly what you want, and it works.
In data-panel::part(body):state(loading), the :state(loading) is a condition on the part. Parts are ordinary elements in the shadow tree; a div has no ElementInternals and therefore no state set, so the condition is unsatisfiable. The selector is well-formed and permanently false.
There is a second, independent restriction. The specification limits which pseudo-classes may follow ::part() to a defined list — the user-action pseudo-classes such as :hover, :focus, :focus-visible, and :active, plus a small set of others. This exists so that a consumer cannot use the part as a foothold to probe arbitrary internal structure. ::part(body):hover is allowed and useful; ::part(body):first-child is not, because it would leak the component’s internal arrangement.
::part(). ::part(body) span matches nothing, ever. A pseudo-element is the end of the selector's reach into the shadow tree; you cannot descend from it. If a consumer needs to style something inside a part, that something needs its own part attribute. This is the same containment principle as the pseudo-class allowlist: parts expose exactly what the component chose to expose, and nothing adjacent to it.
The composition also interacts with exportparts. When a component nests another component and forwards its parts outward, the state condition still applies to whichever host is on the left of the pseudo-element. outer-card:state(loading)::part(inner-body) conditions on the outer host’s state — the inner component’s states are not visible to that selector at all. A design system that wants an inner state to drive outer styling has to propagate it, usually by having the outer component observe an event and set a state of its own.
Production-Safe Pattern
The component below designs the two surfaces together: every region a consumer might want to restyle is a part, and every condition a consumer might want to restyle it under is a state.
class DataPanel extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; border: 1px solid #cbd5f5; border-radius: 10px; }
/* Parts are named for their ROLE, so the names survive a redesign. */
[part~="header"] { padding: 0.75rem 1rem; font-weight: 700; }
[part~="body"] { padding: 1rem; transition: filter 160ms ease; }
[part~="footer"] { padding: 0.5rem 1rem; font-size: 0.85rem; }
[part~="action"] { border-radius: 8px; padding: 0.4rem 0.8rem; }
/* The component's own state styling, for reference. */
:host(:state(loading)) [part~="body"] { filter: grayscale(1); opacity: 0.7; }
:host(:state(error)) { border-color: #c62828; }
@media (prefers-reduced-motion: reduce) {
[part~="body"] { transition: none; }
}
</style>
<div part="header"><slot name="title"></slot></div>
<div part="body"><slot></slot></div>
<div part="footer">
<button part="action" type="button">Refresh</button>
</div>`;
}
setPhase(phase) {
const { states } = this.#internals;
for (const name of ['loading', 'error', 'stale']) {
if (name !== phase) states.delete(name);
}
if (phase) states.add(phase);
this.dispatchEvent(new CustomEvent('phase-change', {
detail: { phase }, bubbles: true, composed: true
}));
}
}
customElements.define('data-panel', DataPanel);
/* Consumer stylesheet — the full vocabulary, composed correctly. */
/* Unconditional part styling. */
data-panel::part(header) { background: #f6f8ff; }
/* State-conditioned part styling: condition BEFORE the pseudo-element. */
data-panel:state(loading)::part(body) { filter: blur(1px); }
data-panel:state(error)::part(header) { color: #842029; }
data-panel:state(stale)::part(footer) { text-decoration: line-through; }
/* User-action pseudo-class AFTER the pseudo-element: allowed. */
data-panel::part(action):hover { background: #e3eaf9; }
data-panel::part(action):focus-visible { outline: 2px solid #2a49d8; }
/* Both at once: host condition on the left, user action on the right. */
data-panel:state(loading)::part(action):hover { cursor: progress; }
/* Combining several states needs :is() on the host side. */
data-panel:is(:state(loading), :state(stale))::part(body) { opacity: 0.8; }
Read the consumer block as two independent vocabularies meeting at the pseudo-element. To the left: which component, in which condition. To the right: which region, in which interaction. Every valid selector in this feature fits that sentence, and every invalid one violates it.
Note the last rule. Because :state() accepts a single identifier, alternation has to be expressed with :is() on the host compound — the same restriction described in styling custom states with the :state() selector, and it applies unchanged here.
Verification
const panel = document.querySelector('data-panel');
const body = panel.shadowRoot.querySelector('[part~="body"]');
// Baseline: no state, no conditional styling.
console.assert(getComputedStyle(body).filter === 'none', 'body unfiltered when idle');
// The supported composition applies.
panel.setPhase('loading');
console.assert(getComputedStyle(body).filter !== 'none', 'state-conditioned part rule applied');
// The unsupported composition never applies. Add it at runtime and confirm.
const sheet = new CSSStyleSheet();
sheet.replaceSync('data-panel::part(body):state(loading) { outline: 3px solid red; }');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
console.assert(
getComputedStyle(body).outlineStyle === 'none',
'condition after ::part() must not match'
);
// Descendant reach past a part is impossible.
const sheet2 = new CSSStyleSheet();
sheet2.replaceSync('data-panel::part(body) span { color: red; }');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet2];
const span = body.querySelector('span') ?? body.appendChild(document.createElement('span'));
console.assert(getComputedStyle(span).color !== 'rgb(255, 0, 0)', 'no descending from a part');
panel.setPhase(null);
In DevTools, select the part element inside the shadow tree and read the Styles pane: consumer rules that reach it are listed under the host’s document stylesheet, clearly separated from the component’s own shadow rules. A rule that appears nowhere in that list did not match, which is the fastest way to distinguish “my declaration was overridden” from “my selector never applied”.
When to Use vs When to Avoid
| Consumer intent | Selector |
|---|---|
| Restyle a region unconditionally | my-el::part(body) |
| Restyle a region while the component is in a state | my-el:state(x)::part(body) |
| Restyle a region on hover or focus | my-el::part(body):hover |
| Restyle a region on hover while in a state | my-el:state(x)::part(body):hover |
| Restyle a region under any of several states | my-el:is(:state(a), :state(b))::part(body) |
| Restyle something inside a region | Not possible — the component must expose it as its own part |
| Restyle a region based on an inner component’s state | Not possible — propagate it to the outer host first |
The two “not possible” rows are the design signal. If consumers keep asking for either, the component’s part vocabulary is too coarse or its state vocabulary is incomplete — the fix is on the component side, adding a part or propagating a state, not a cleverer selector on the consumer side.
Frequently Asked Questions
Why does ::part(body):state(loading) never match?
Because the condition after the pseudo-element applies to the part, and parts are ordinary shadow-tree elements with no custom state set. Move the condition to the left of ::part(), where it applies to the host that actually owns the state.
Which pseudo-classes are allowed after ::part()?
Chiefly the user-action ones — :hover, :focus, :focus-visible, :focus-within, :active — plus a small standardised set. Structural pseudo-classes are excluded deliberately, so a consumer cannot probe the component’s internal arrangement.
Can I style a descendant of a part?
No. A pseudo-element ends the selector’s reach, so ::part(body) span matches nothing. If consumers need that element, give it its own part attribute.
Does an inner component's state reach an outer component's part selector?
No. The condition on the left of ::part() always refers to the host named there. To drive outer styling from inner state, have the outer component listen for the inner component’s event and set a state of its own.
Related
- Custom States & State-Driven Styling — the parent topic on
CustomStateSet. - Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Part & Slotted Selectors — the part vocabulary this composes with.
- Styling Custom States with the :state() Selector — the selector forms in detail.
- Exposing Loading States to Consumer CSS — designing the two vocabularies together.