CSS :state() Pseudo-Class in Custom Web Components

This article explores the CSS :state() pseudo-class, a feature of the Custom State Set API that allows developers to style autonomous custom web components based on their internal, custom states. By reading this guide, you will learn why :state() was introduced, how it replaces fragile attribute- and class-based patterns, and how to implement it directly within JavaScript and CSS to achieve clean encapsulation.


What is the :state() Pseudo-Class?

The :state() pseudo-class represents custom internal states exposed by a custom element. Historically, native HTML elements have built-in pseudo-classes to reflect their interactive states, such as :checked for checkboxes, :disabled for buttons, or :hover.

Before the introduction of the Custom State API, custom elements could not define their own pseudo-classes. Instead, developers had to use custom HTML attributes (e.g., loading="true") or utility classes (e.g., class="is-loading"). The CSS :state() pseudo-class addresses this limitation by allowing custom components to expose internal states directly to CSS selectors, behaving identically to built-in pseudo-classes.

The Problem It Solves

Using attributes or classes to track internal component state introduces several problems:

  • DOM Pollution: State-tracking attributes clutter the DOM and expose implementation details.
  • Encapsulation Leaks: Users of the component might accidentally remove or manipulate classes or attributes that the component relies on to function.
  • Selector Specificity Issues: Class- and attribute-based selectors increase specificity unpredictably compared to native pseudo-classes.

The :state() pseudo-class solves this by separating private internal state management from the public DOM tree.

How the Custom State API Works

The feature relies on two key parts: the CustomStateSet interface in JavaScript and the :state() selector in CSS.

1. Managing States in JavaScript

To expose a state, an autonomous custom element calls this.attachInternals() to get an ElementInternals object. This object contains a states property, which is an instance of CustomStateSet—a Set-like collection of strings representing active states.

class ToggleSwitch extends HTMLElement {
  constructor() {
    super();
    this.internals = this.attachInternals();
    this.addEventListener('click', this._toggle);
  }

  _toggle() {
    if (this.internals.states.has('checked')) {
      this.internals.states.delete('checked');
    } else {
      this.internals.states.add('checked');
    }
  }
}

customElements.define('toggle-switch', ToggleSwitch);

Whenever this.internals.states.add('checked') is called, the element enters that state. Calling this.internals.states.delete('checked') exits it.

2. Selecting States in CSS

Consumers of the component, as well as the component's internal Shadow DOM, can style elements based on these custom identifiers using :state().

External Styling: Consumers style the element from the document level:

toggle-switch:state(checked) {
  border-color: #007bff;
  background-color: #e6f0ff;
}

Internal Styling (Shadow DOM): The component can style itself internally using the :host() functional pseudo-class:

:host(:state(checked)) {
  --thumb-position: 100%;
}

Key Benefits

  • Strong Encapsulation: States can only be set or modified by the component’s internal JavaScript. External code cannot modify the state via setAttribute or classList.
  • Standard-Aligned API: Custom components can mimic the styling behavior and ergonomics of native platform controls.
  • Cleaner HTML: The rendered HTML remains clean, with no unnecessary aria-* duplications or state classes cluttering the element markup.