
A Private Vocabulary for Your Component’s State
Learn why the CustomStateSet API is the final piece of the encapsulation puzzle for developers tired of polluting their HTML with styling-only attributes.
Stop looking at your browser's inspector for a second and think about the last time you built a complex UI component. You probably had a dozen different visual states—loading, error, collapsed, validating, pressed—and to make those states reachable by CSS, you likely slapped a bunch of data-* attributes or boolean attributes onto your host element.
It works, but it feels like a leak. Every time you call this.setAttribute('data-loading', 'true'), you’re not just updating a style; you’re modifying the public-facing DOM. You’re inviting external scripts to mess with your internal logic, and you’re forcing the browser’s parser to do work that should really just be a style calculation. The CustomStateSet API, accessible via ElementInternals, changes this by giving your Web Components a private vocabulary for state—one that stays inside the component while remaining fully accessible to your CSS.
The Problem with Attribute Pollution
For years, we’ve treated attributes as the primary bridge between JavaScript logic and CSS styling. If a button is "busy," we give it a busy attribute.
// The old way
class MyButton extends HTMLElement {
set loading(value) {
if (value) {
this.setAttribute('loading', '');
} else {
this.removeAttribute('loading');
}
}
}This works, but it’s clumsy. Attributes were originally intended for configuration and accessibility (like aria-expanded or href), not as a dumping ground for every transient state your component goes through.
When you use attributes for styling, you run into three main issues:
1. Observability overhead: If you have a MutationObserver watching your component, it triggers every time you toggle a styling attribute.
2. Public API bloat: Anyone can open the console and run $0.removeAttribute('loading'). If your component's internal logic depends on that attribute existing, you’ve just created a bug because an external actor touched your "private" state.
3. Reflected complexity: You have to manage the synchronization between your internal property (this._loading) and the DOM attribute. It’s boilerplate code that adds zero value.
Enter ElementInternals and CustomStateSet
The CustomStateSet is a part of the ElementInternals API. If you haven't used ElementInternals yet, it’s essentially the "backdoor" for component authors. it allows your component to participate in forms, manage ARIA roles internally, and—crucially—manage custom states.
To get started, you call this.attachInternals() in your constructor. This returns an object that gives you access to the states property.
class SmartToggle extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
}
toggle() {
const states = this._internals.states;
if (states.has('checked')) {
states.delete('checked');
} else {
states.add('checked');
}
}
}
customElements.define('smart-toggle', SmartToggle);In this example, checked is now an internal state. If you inspect the <smart-toggle> element in your DevTools, you won't see a checked attribute. You won't see data-checked. The DOM remains clean. However, your CSS can still see it.
Styling via the :state() Pseudo-class
This is where the magic happens. The CSS Selector Level 4 spec introduced the :state() pseudo-class (formerly :--state-name in earlier implementations). It allows you to target those internal states from your stylesheet.
smart-toggle {
width: 50px;
height: 25px;
background: gray;
display: inline-block;
transition: background 0.2s;
}
/* Targeting the internal state */
smart-toggle:state(checked) {
background: blue;
}Notice the syntax: :state(checked). It looks and feels like :hover or :focus. This is intentional. These are "pseudo-states," exactly like the native ones the browser uses for checkboxes or links, but *you* get to define what they mean.
Why this is better than Classes or Attributes
If you use a class like .is-checked, you’re still polluting the class attribute, which a user might want to use for their own styling. If you use a data- attribute, you're dealing with string parsing.
:state() is a first-class citizen in the CSS engine. It is more performant because the browser doesn't have to re-parse an attribute string or look through a class list; it just checks a bitmask or a internal Set on the element.
A Practical Example: The "Async Action" Button
Let’s build something a bit more robust. Imagine a button that handles a network request. It has three states: idle, requesting, and success. Using attributes, this becomes a mess of setAttribute calls. Using CustomStateSet, it becomes a clean state machine.
class AsyncButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._internals = this.attachInternals();
const btn = document.createElement('button');
btn.textContent = 'Save Changes';
btn.onclick = () => this.execute();
this.shadowRoot.append(btn);
}
async execute() {
const states = this._internals.states;
// Clear previous success state
states.delete('success');
// Enter requesting state
states.add('requesting');
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
states.delete('requesting');
states.add('success');
} catch (e) {
states.delete('requesting');
// Maybe add an 'error' state here?
}
}
}
customElements.define('async-button', AsyncButton);Now, look how expressive the CSS becomes:
async-button:state(requesting) button {
cursor: wait;
opacity: 0.7;
}
async-button:state(requesting) button::after {
content: '...';
}
async-button:state(success) button {
background-color: green;
color: white;
}The user of your component doesn't need to know how requesting is managed. They don't see it in the HTML. It’s a private contract between your JavaScript logic and your styling layer.
The Hidden Power of ElementInternals
It’s worth pausing to talk about why this.attachInternals() is the way this works. Before this API, Web Components were a bit "naked." They didn't have a way to tell the browser "I am a checkbox" or "I am currently invalid" without relying on the same attributes that a user could easily delete.
The ElementInternals object is the definitive way to handle accessibility and form state. When you use states.add(), you are building a component that feels native.
Gotcha: The Shadow DOM Requirement?
A common question is whether you *must* use Shadow DOM to use CustomStateSet. While ElementInternals is most powerful within the Shadow DOM (specifically for form association), states works on any Custom Element that calls attachInternals(). However, keep in mind that :state() is only reachable if the CSS is scoped correctly. If you are using global CSS to style your component, :state() works perfectly. If you are styling from *inside* the Shadow DOM, you’d use :host(:state(checked)).
/* Inside the Shadow DOM style tag */
:host(:state(checked)) {
border: 2px solid blue;
}State vs. ARIA: Don't Confuse the Two
This is a critical distinction that many developers miss. Just because you have a :state(loading), it doesn't mean a screen reader knows the component is loading.
CustomStateSet is for styling.ElementInternals.ariaBusy (or the aria-busy attribute) is for semantics.
Often, you will want to toggle both at the same time:
set loading(isLoading) {
if (isLoading) {
this._internals.states.add('loading');
this._internals.ariaBusy = 'true';
} else {
this._internals.states.delete('loading');
this._internals.ariaBusy = 'false';
}
}Wait, if we still have to use aria-busy, why bother with states? Why not just style based on the ARIA attribute like async-button[aria-busy="true"]?
You *can* do that. But it's poor practice to tie your visual design strictly to your accessibility layer. Sometimes a component might be in a visual "active" state that doesn't correspond to a standard ARIA attribute. Or perhaps you want multiple visual states that all map to a single ARIA role. Using CustomStateSet keeps your styling concerns separate from your semantic concerns.
Performance: Why Your Browser Will Thank You
When you change an attribute on an element, the browser has to:
1. Check if that attribute is "observed" by attributeChangedCallback.
2. Update the DOM tree.
3. Re-run selector matching for the entire affected subtree.
4. Sync the change to any accessibility trees or mutation observers.
While modern browsers are incredibly fast at this, doing this 60 times a second (e.g., during an animation or a rapid-fire interaction) adds up. CustomStateSet skips the DOM serialization part. You are updating a bit in a Set, and the CSS engine is notified that the :state() pseudo-class might now apply. It’s a more direct path to the pixels.
I’ve found this particularly useful in complex data grids where rows might have states like selected, editing, dirty, and invalid. Updating these via attributes on 500 rows can lead to noticeable lag. CustomStateSet keeps the interaction snappy.
Browser Support and the "State" of the Spec
The CustomStateSet API is relatively new but has broad support in modern Chromium browsers (Chrome, Edge, Opera) and recently landed in Safari (17.4+). Firefox has support behind a flag and is moving toward full implementation.
If you need to support older browsers, you’ll need a polyfill, but the beauty of this API is that it degrades gracefully. If :state() isn't supported, your styles just won't apply. For many projects, you can use a small helper function that toggles a data-state attribute as a fallback.
The Syntax Evolution
One thing to watch out for is that the syntax changed during the proposal process. You might see older blog posts referring to :--checked (the double-dash syntax). That is now deprecated in favor of the functional :state(checked) syntax. If you want to be safe and compatible with the latest spec, stick to the functional version.
When Should You Still Use Attributes?
I’m not suggesting you delete every setAttribute call in your codebase. Attributes are still the correct choice for:
* Initial configuration: If a user needs to set a theme="dark" or limit="10" on your component.
* Accessibility: As mentioned, aria-* and role are vital for the screen reader experience.
* Data reflecting: If you want the state to be easily readable and writable by a human or a simple script (e.g., input value).
Use CustomStateSet for the internal mechanical states of your component. If the state exists purely to change how the component looks, it belongs in the state set.
Wrapping Up: A Cleaner Component Architecture
We’ve spent years making do with what the DOM gave us, often twisting attributes into shapes they weren't meant to hold. The CustomStateSet API is the final piece of the encapsulation puzzle. It allows us to build components that are truly "black boxes"—where the internal logic stays internal, and the styling interface is explicit and performant.
Next time you find yourself writing this.setAttribute('data-is-open', 'true'), stop and ask yourself: "Does the outside world need to know this is open, or do I just need to change the CSS?" If it's the latter, reach for ElementInternals.states. Your DOM (and your future self) will thank you.
Summary Checklist for Implementation:
1. Call `this.attachInternals()` in your constructor and store the reference.
2. Use `this._internals.states.add('your-state')` to trigger a state change.
3. Target with `:state(your-state)` in your CSS.
4. Keep it private: Don't reflect these states back to attributes unless there is a clear functional reason to do so.
5. Don't forget ARIA: Map your internal states to semantic properties separately to ensure everyone can use your component.


