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.

Which element the state condition attaches to on each side of ::part() Placed before the pseudo-element the condition applies to the host, which owns the state; placed after it the condition applies to the part element, which has no state set. The pseudo-element is the pivot: conditions before it describe the host data-panel:state(loading)::part(body) host: data-panel has the state — condition matches part: div[part~="body"] the subject that gets styled condition on the host, subject in the shadow tree this is the supported composition data-panel::part(body):state(loading) host: data-panel unconditioned part: div[part~="body"] a div has no custom state set condition can never be satisfied valid syntax, permanent non-match

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.

Debugging Pitfall — descendant selectors do not work after ::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.

What a consumer may and may not write after a part selector User-action pseudo-classes are permitted after a part, while structural pseudo-classes, custom states and descendant combinators are not, because they would expose internal structure. Allowed after ::part() ::part(body):hover ::part(action):focus-visible ::part(action):active ::part(body) — plain, unconditioned user-action state, which the consumer already causes Not allowed / never matches ::part(body):state(loading) ::part(body):first-child ::part(body) span ::part(body) > * anything that would reveal internal structure

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.

The consumer styling surface as a grid of parts by states Each exposed part crossed with each published state gives a cell a consumer can target with one selector, which is why the two vocabularies should be designed together. Parts × states: every cell is one consumer selector part \ state idle loading error stale header colour shift body blur fade footer strike-through A coarse part list or a thin state list shrinks this grid — and shrinks what consumers can express without forking.

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.