Custom States & State-Driven Styling

Native elements expose their internal state to CSS through pseudo-classes: :checked, :disabled, :invalid, :open. A custom element has state too — loading, expanded, selected, dragging, out-of-range — and for years the only way to expose it was to reflect a boolean attribute and let consumers write my-element[loading]. That works, and it has a real cost: the attribute is part of the DOM, so a consumer can set it, a framework can overwrite it, a sanitizer can strip it, and a serializer captures a transient UI state as if it were authored markup.

CustomStateSet closes the gap. ElementInternals.states is a set-like object whose members are matched by the :state() pseudo-class, giving a component the same kind of state channel the platform gives <input> — visible to CSS, invisible to the DOM, and writable only from inside the component. This is the parent topic for state-driven styling inside Styling, Theming & CSS Encapsulation.

Attribute reflection versus a custom state set A reflected attribute is writable by anyone and appears in serialized markup, while a custom state lives on ElementInternals, is writable only by the component, and is visible only to CSS. Two channels for the same idea: "this component is loading" Reflected attribute this.toggleAttribute('loading', true) selector: my-card[loading] anyone can set or clear it appears in serialized HTML a framework re-render overwrites it right for configuration, wrong for UI state Custom state this.#internals.states.add('loading') selector: my-card:state(loading) only the component can write it never serialized survives framework reconciliation right for transient internal state
Spec authority. CustomStateSet and the states property of ElementInternals are defined in the HTML Standard's custom elements section. The matching pseudo-class :state() is defined in CSS Selectors Level 4 (custom state pseudo-class), which also specifies that it matches in the same way as other structural pseudo-classes and participates in specificity as a pseudo-class. ElementInternals itself, obtained via attachInternals(), is the same object used for form association and ARIA defaults.

How the State Set Reaches the Cascade

attachInternals() returns an ElementInternals object, and its states property is a CustomStateSet — a Set<string> with the ordinary add, delete, has, and clear methods. Adding a string makes :state(thatString) match the host element; deleting it stops the match. There is no event, no attribute, and no mutation record: the only observable effect is on style resolution and on matches().

Three properties define how it behaves in the cascade.

It is a pseudo-class on the host. :state(loading) has pseudo-class specificity (0,1,0), identical to :hover or [loading]. Inside a shadow tree it is written :host(:state(loading)), exactly as other host conditions are. From outside, a consumer writes my-card:state(loading).

It is one-way. The DOM has no reflection of the state set, so getAttribute returns nothing, outerHTML shows nothing, and a MutationObserver sees nothing. Consumers who need to observe the state need the component to dispatch an event — the state set is a styling channel, not a notification channel.

It is per-instance and per-element. Two instances of the same component hold independent sets. States are not inherited and do not propagate into the shadow tree; a rule inside the shadow tree targeting :state(loading) on a descendant matches only if that descendant is itself a custom element with that state.

The naming rule is worth stating because it changed during standardisation. Current engines accept a plain identifier — states.add('loading'), matched by :state(loading). An earlier Chromium implementation required a dashed ident and used :--loading syntax. Code written against the old shape still exists in the wild; the migration is covered in migrating from dashed-ident custom states.

Core API Surface

Member Type Behaviour
element.attachInternals() ElementInternals Throws if called twice, or on a built-in element. Call once, in the constructor.
internals.states CustomStateSet Set-like; live, per instance.
states.add(name) void :state(name) starts matching. Idempotent.
states.delete(name) boolean Returns whether the state was present.
states.has(name) boolean The read path — cheaper than matches().
states.clear() void Removes every state at once.
states.size number Count of active states.
:state(name) selector Matches the host; specificity (0,1,0).
:host(:state(name)) selector The in-shadow form.
element.matches(':state(name)') boolean Works, and is the interop-safe read from outside.

Two members carry hidden value. states.clear() is the correct way to reset a component to a known baseline in disconnectedCallback or on an error boundary, rather than deleting each state by name and forgetting one. And has() is a plain set lookup with no selector parsing, which makes it the right choice inside a hot path such as a pointer-move handler.

What a state change touches and what it does not Adding a custom state invalidates style for the host and triggers matching rules inside and outside the shadow tree, while leaving attributes, serialization and mutation observers untouched. states.add('loading') synchronous, no event Style invalidated :host(:state(loading)) my-card:state(loading) host element only Readable in script internals.states.has(…) el.matches(':state(…)') no parsing in has() Untouched attributes, outerHTML MutationObserver serialization, SSR output Consumers who must react in script need a dispatched event: the state set is a styling channel, not a notification one.

Production Implementation Pattern

The component below drives four states from real behaviour, exposes them to consumer CSS, and pairs each with an event so application code can react without polling.

class AsyncPanel extends HTMLElement {
  #internals;
  #controller = null;

  constructor() {
    super();
    // attachInternals() must be called exactly once, in the constructor.
    this.#internals = this.attachInternals();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: block; border: 1px solid #cbd5f5; border-radius: 10px; padding: 1rem; }

        /* The component's own visual response to its own state. */
        :host(:state(loading)) { opacity: 0.6; cursor: progress; }
        :host(:state(error))   { border-color: #c62828; }
        :host(:state(empty))   { border-style: dashed; }

        .spinner { display: none; }
        :host(:state(loading)) .spinner { display: block; }

        .message { display: none; }
        :host(:state(error)) .message { display: block; color: #c62828; }
      </style>
      <div class="spinner" role="status" aria-live="polite">Loading…</div>
      <p class="message">Could not load. Retry?</p>
      <slot></slot>`;
  }

  connectedCallback() {
    this.#controller = new AbortController();
    this.load();
  }

  disconnectedCallback() {
    this.#controller?.abort();
    this.#controller = null;
    // One call resets every state; no risk of leaving one behind.
    this.#internals.states.clear();
  }

  async load() {
    const { states } = this.#internals;
    states.delete('error');
    states.delete('empty');
    states.add('loading');
    this.#announce('loading');

    try {
      const response = await fetch(this.getAttribute('src'), {
        signal: this.#controller.signal
      });
      const items = await response.json();
      states.delete('loading');
      // CustomStateSet has add/delete/has/clear — there is no toggle helper.
      if (items.length === 0) states.add('empty');
      else states.delete('empty');
      this.#announce(items.length === 0 ? 'empty' : 'ready');
    } catch (error) {
      if (error.name === 'AbortError') return;   // teardown, not a failure
      states.delete('loading');
      states.add('error');
      this.#announce('error');
    }
  }

  #announce(phase) {
    // The state set is invisible to script outside the component, so the
    // component must publish the same information as an event.
    this.dispatchEvent(new CustomEvent('phase-change', {
      detail: { phase }, bubbles: true, composed: true
    }));
  }
}
customElements.define('async-panel', AsyncPanel);
/* Consumer stylesheet — no attribute, nothing to accidentally overwrite. */
async-panel:state(loading) { --skeleton-opacity: 0.4; }
async-panel:state(error)   { outline: 2px solid #c62828; }
async-panel:state(empty)::after { content: 'Nothing here yet'; }

The pattern to copy is the pairing: state for styling, event for scripting. Neither substitutes for the other. A component that only sets states leaves application code polling matches(); a component that only dispatches events forces consumers to write JavaScript for something CSS should do.

Debugging Pitfall — attachInternals() called twice. The second call throws NotSupportedError. This bites when a base class calls it and a subclass calls it again, or when a hot-reloading dev server re-evaluates a module and re-runs a constructor against an existing element. Call it once in the constructor of the class that owns internals, store the result in a private field, and have subclasses use an accessor rather than calling it themselves.

Designing the State Vocabulary

A component’s set of state names is public API in everything but enforcement. Consumers write stylesheets against it, screenshots capture it, and design documentation references it — so renaming busy to loading in a minor release breaks pages exactly as removing an attribute would. Treat the vocabulary with the same care as the event names discussed in event composition and bubbling.

Four rules keep a vocabulary useful over time.

Name the condition, not the styling. loading describes what is true; dimmed describes what the component currently does about it. The first survives a redesign, the second does not. The same reasoning that makes --color-danger a better token name than --color-red applies here, and is developed in implementing design tokens with CSS custom properties.

Prefer orthogonal states to a single enum. Modelling phase as loading / ready / error requires the component to delete the previous value on every transition, and one missed delete leaves two states matching at once. Where conditions genuinely can coexist — loading while stale — orthogonal states are correct. Where they cannot, a small helper that clears the group before setting the new member removes the whole class of bug:

/** Set exactly one member of a mutually exclusive group. */
const setPhase = (states, group, next) => {
  for (const name of group) {
    if (name !== next) states.delete(name);
  }
  if (next) states.add(next);
};

setPhase(this.#internals.states, ['loading', 'ready', 'error', 'empty'], 'error');

Keep the set small. Every state is a promise to keep matching under the same conditions forever. Three to six states cover almost every component; a dozen usually means internal implementation detail has leaked into the public surface. A useful test before adding one: could this condition be a class inside the shadow tree instead? If the answer is yes and no consumer would reasonably style it, keep it internal where it stays changeable.

Document the trigger, not just the name.:state(stale) matches while the panel is showing data older than the max-age attribute allows” tells a consumer when they can rely on it. “:state(stale) — stale” tells them nothing they could not guess, and guarantees a support question.

Common Failure Modes & Debugging Steps

1. The selector never matches. Check the syntax generation your engine expects. A component calling states.add('loading') with a stylesheet written as :--loading matches nothing on a current engine, and the reverse fails on an old one. Confirm with el.matches(':state(loading)') in the console — a true there with no visual change means the selector in the stylesheet is the problem, not the state.

2. State survives a reset it should not. states is per instance and persists until deleted, including across disconnection and reconnection. A component that adds error and is then moved in the DOM keeps the error styling. Call clear() in disconnectedCallback, or re-derive state on connection.

3. Consumer script cannot observe the state. By design. There is no reflection and no mutation record. Dispatch an event alongside every state change, and document the event as the public contract — the same discipline as contract testing custom event payloads.

4. Specificity fights with an attribute selector. :state(x) and [x] both weigh (0,1,0), so a component that supports both channels during a migration will resolve ties by source order. Pick one channel per state rather than shipping both, and where both must exist temporarily, make the attribute the compatibility shim and the state the source of truth.

State transitions through one load cycle, and where a reset is required The panel moves from idle to loading and then to ready, empty or error, and a disconnection must clear the set or the error styling survives a move. idle empty set loading add('loading') ready no state set empty add('empty') error add('error') disconnect states.clear() Without clear(), a moved component reconnects still wearing its error styling: the set is per instance and persists across disconnection.

Framework Interop

Because states never touch attributes, they are invisible to React’s reconciler, Vue’s patcher, and Angular’s change detection — which is exactly why they are robust. A framework re-render that rewrites every attribute on the host cannot clear a custom state, so a loading indicator does not flicker off when a parent re-renders. This is the concrete advantage over reflected attributes in framework-heavy applications, and it is the reason to prefer states for anything transient.

Server rendering has the mirror-image property: states do not exist until the element upgrades, so a server-rendered panel cannot ship with :state(loading) already applied. Where a pre-upgrade appearance matters, use :not(:defined) for the pre-upgrade phase — the technique in graceful degradation without JavaScript — and let the state take over after upgrade.

There is one interop detail that catches teams migrating an existing library. Because attachInternals() is also how a component becomes form-associated and how it publishes ARIA defaults, a component that already uses internals for either purpose already has the object it needs — internals.states is simply another property on it. Conversely, a component that has never called attachInternals() must add the call in its constructor, and doing so has a side effect worth knowing: once internals are attached, the element is eligible for form association if formAssociated is also declared, and the browser will begin including it in form submission. Adding states to a component that was not form-associated changes nothing, but the two features share an entry point, which is why the form-associated custom elements material and this page keep pointing at each other.

Testing custom states is more pleasant than testing attributes, because the assertion is about rendered behaviour rather than markup. element.matches(':state(loading)') is the direct read; a visual regression run captures the styling consequence. What a test cannot do is inspect the markup, so a snapshot suite that asserts on outerHTML will show no difference between a loading panel and a ready one — which is a feature of the design and a trap for a test suite that was written around attributes. Assert on the selector or on the dispatched event, never on the serialized DOM.

Performance & Memory Implications

A state change invalidates style for exactly one element: the host. It does not touch attributes, so there is no attribute-changed reaction, no MutationObserver record, and no serialization work. Compared with toggleAttribute, which does all of the above, a state toggle is measurably cheaper in components that flip state on pointer move or scroll.

The set itself is a small collection of strings held by ElementInternals, which is held by the element. It is collected with the element and needs no teardown for memory reasons — clear() in disconnectedCallback is about correctness, not leaks.

There is a second, less obvious performance argument in favour of states over attributes in animation-adjacent code. Toggling an attribute on the host invalidates style for the host and re-evaluates every attribute selector in every stylesheet that could match it, including consumer stylesheets the component knows nothing about. A custom state invalidates only rules containing :state(), which in practice is a much smaller set because nothing in a typical application’s CSS uses it accidentally. For a component that flips state on every pointer move — a slider showing dragging, a canvas showing panning — the difference is visible in a Performance recording as a shorter Recalculate Style band per frame.

The one cost to watch is selector-matching breadth. A consumer rule like :state(active) * forces a descendant match on every state change; keep consumer-facing state selectors anchored to the host and let the component’s own shadow rules handle its interior. Documenting that expectation alongside the state vocabulary costs one sentence and prevents a whole class of application-side performance regression.

Browser Compatibility & Fallback Strategy

Feature Chromium Firefox Safari
ElementInternals / attachInternals() 77 93 16.4
CustomStateSet (internals.states) 90 126 17.4
:state() selector syntax 125 126 17.4
Legacy :--name syntax 90–124 never never

The practical floor is Safari 17.4 and Firefox 126, both from 2024. For a component library that must support older engines, the compatible strategy is a private attribute prefixed to signal that it is not public API — data-wfc-state-loading — set alongside the custom state, with consumer documentation referencing only :state(). When the floor moves, the attribute is deleted and no documented API changes.

Frequently Asked Questions

Can a consumer set or clear a custom state?

No. The set is reachable only through the ElementInternals object returned by attachInternals(), which the component keeps private. That one-way property is the point: transient UI state cannot be corrupted from outside.

How do I observe a custom state from application code?

You cannot observe it directly — there is no reflection and no mutation record. Read it on demand with element.matches(':state(name)'), and have the component dispatch an event whenever the state changes so consumers can react without polling.

Do custom states appear in serialized HTML?

Never. outerHTML, getHTML(), and server-rendered output all omit them, which is why a snapshot test cannot accidentally capture a transient loading state as if it were authored markup.

Should I replace all my reflected boolean attributes with states?

Only the ones representing internal, transient state. Attributes remain correct for configuration a consumer sets — disabled, open on a details-like component, variant — because those genuinely belong in the markup and should serialize.