
A Better Way to Tell CSS Your Component Is Busy
Stop cluttering your DOM with data-attributes and learn how the Custom State Set API provides a cleaner, encapsulated way to style the internal logic of your Web Components.
Data attributes are a leaky abstraction that we’ve collectively accepted as "good enough" for styling component state. We’ve spent a decade slapping data-loading="true" or data-invalid onto our elements, essentially using the DOM as a global variable store. It works, sure, but it exposes internal logic to the outside world in a way that is clunky, prone to accidental overrides, and frankly, a bit of a mess to maintain.
If you’re building Web Components, there is a significantly more elegant way to handle this. The Custom State Set API allows you to define internal states that are accessible to CSS via the :state() pseudo-class, without ever touching a data- attribute or polluting the visible DOM.
The Problem with the "Data" Way
Let’s look at how most of us currently handle a "busy" or "loading" state in a Web Component. You likely have a class that looks something like this:
class LoadingButton extends HTMLElement {
#loading = false;
set loading(value) {
this.#loading = Boolean(value);
if (this.#loading) {
this.setAttribute('data-loading', '');
} else {
this.removeAttribute('data-loading');
}
}
get loading() {
return this.#loading;
}
}Then, in your CSS, you style it:
loading-button[data-loading] .spinner {
display: block;
}
loading-button[data-loading] .label {
opacity: 0.5;
}This feels fine until you realize that anyone can open the browser Inspector, delete that data-loading attribute, and potentially break your component's visual state while the internal #loading variable is still true. Or worse, an external script tries to use data-loading for its own purposes on the same element. We are using a public-facing API (attributes) to represent an internal-only state.
Enter ElementInternals and states
The Custom State Set API is part of the ElementInternals interface. It gives us a way to tell the browser: "This component is currently in a specific state, but the outside world doesn't need to see the implementation details in the DOM tree."
To use it, you first need to call attachInternals() in your constructor. This returns an object that provides a gateway to internal component features, including the states property.
Here is the updated, cleaner version of our button:
class BusyButton extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
padding: 10px 20px;
background: blue;
color: white;
cursor: pointer;
}
/* Internal styling for the busy state */
:host(:state(busy)) {
background: gray;
cursor: wait;
}
</style>
<slot></slot>
`;
}
set busy(isBusy) {
if (isBusy) {
this.#internals.states.add('busy');
} else {
this.#internals.states.delete('busy');
}
}
get busy() {
return this.#internals.states.has('busy');
}
}
customElements.define('busy-button', BusyButton);Why this is better
1. Encapsulation: If you inspect <busy-button> in the DOM while it's busy, you won't see any extra attributes. The state is held in the CustomStateSet (which acts like a Set of strings).
2. Performance: Modifying attributes triggers MutationObservers and requires the browser to parse string values. Toggling a state in a CustomStateSet is much closer to the metal.
3. Semantic CSS: The :state(busy) pseudo-class is explicitly designed for this. It feels like a native part of the language, much like :hover or :checked.
Selecting the State from the Outside
One of the coolest parts of this API is how it handles external styling. If a consumer of your component wants to style the busy state, they don't have to guess which data attribute you used. They use the same pseudo-class.
/* Styling the component from the main document */
busy-button:state(busy) {
border: 2px solid red;
}This creates a clear contract. You, the component author, define what "busy" means internally. The user of the component just hooks into that semantic state.
Mapping Internal Logic to States
The states object is just a set. You can add as many states as you want. This is particularly useful for components with complex lifecycles, like a file uploader or a form.
Consider a UserSearch component that could be in several states: idle, searching, found, or error.
class UserSearch extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
async performSearch(query) {
// Clear previous states
this.#internals.states.delete('found');
this.#internals.states.delete('error');
this.#internals.states.add('searching');
try {
const results = await fetch(`/api/users?q=${query}`);
if (!results.ok) throw new Error();
this.#internals.states.delete('searching');
this.#internals.states.add('found');
} catch (e) {
this.#internals.states.delete('searching');
this.#internals.states.add('error');
}
}
}Now your CSS becomes incredibly declarative:
user-search:state(searching) .spinner { display: block; }
user-search:state(found) .results { display: grid; }
user-search:state(error) .error-msg { display: block; }The Accessibility Component: Don't Forget aria-busy
While :state(busy) is fantastic for CSS, it doesn't automatically tell a screen reader that the component is busy. This is a common trap. Developers often think that because they've solved the visual problem, they've solved the functional problem.
The ElementInternals object also allows you to manage ARIA states directly. You should ideally sync your custom state with the appropriate ARIA attribute.
set busy(isBusy) {
if (isBusy) {
this.#internals.states.add('busy');
this.#internals.ariaBusy = "true";
} else {
this.#internals.states.delete('busy');
this.#internals.ariaBusy = "false";
}
}By using this.#internals.ariaBusy, you are setting the internal ARIA state. This is often better than this.setAttribute('aria-busy', 'true') because it avoids attribute clutter while still communicating correctly with the Accessibility Tree.
Wait, What About the Dash?
If you've looked at early drafts of the Custom State Set API or older tutorials, you might have seen a requirement to prefix states with a double dash, like internals.states.add('--busy').
The spec originally required the -- prefix to avoid collisions with future standard pseudo-classes. However, the consensus shifted. Modern implementations (Chrome 90+, Safari 17.4+, Firefox 126+) generally support the non-prefixed version.
If you're targeting slightly older versions of Chromium, you might need the dash. But for a modern green-field project, busy is cleaner and is where the spec landed.
Dealing with "State Explosion"
One trap I've fallen into is creating too many states. If you find yourself adding internals.states.add('loading-initial'), internals.states.add('loading-more'), and internals.states.add('loading-refresh'), you might be over-engineering your CSS hooks.
State should represent *what* the component is doing in a way that matters to the user interface. If the UI doesn't change between "initial load" and "refresh," don't create two different states for them. Keep the state names high-level and semantic.
Browser Support and Polyfills
As of 2024, support is very strong in Chromium-based browsers and Safari. Firefox was the last major holdout, but they shipped support for :state() in version 126.
If you need to support older browsers, there is a very reliable polyfill: element-internals-polyfill. It handles the states Set and allows you to use a fallback selector.
Usually, the polyfill will map :state(busy) to something like .\:state\(busy\) or a specific attribute. While it defeats the "no attributes" goal in older browsers, it allows you to write modern code today that will automatically become "cleaner" as your user base updates their browsers.
A Full "Busy" Example
Let's put it all together into a robust, accessible, and well-styled component. This is a Data-Frame component—a wrapper that handles fetching data and showing a loading state.
class DataFrame extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.#internals.role = 'region'; // Semantic role
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
position: relative;
min-height: 100px;
transition: opacity 0.3s ease;
}
.overlay {
display: none;
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.7);
align-items: center;
justify-content: center;
}
:host(:state(busy)) {
opacity: 0.8;
pointer-events: none;
}
:host(:state(busy)) .overlay {
display: flex;
}
:host(:state(error)) {
border: 2px dashed red;
}
</style>
<div class="overlay" aria-hidden="true">
<slot name="loader">Loading...</slot>
</div>
<slot></slot>
`;
}
async fetchData(url) {
this.#updateState('busy');
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
const data = await response.json();
this.#updateState('ready');
this.dispatchEvent(new CustomEvent('data-load', { detail: data }));
} catch (err) {
this.#updateState('error');
}
}
#updateState(status) {
// Helper to manage multiple mutually exclusive states
const states = ['busy', 'ready', 'error'];
states.forEach(s => {
if (s === status) {
this.#internals.states.add(s);
} else {
this.#internals.states.delete(s);
}
});
// Sync ARIA
this.#internals.ariaBusy = status === 'busy' ? 'true' : 'false';
}
}
customElements.define('data-frame', DataFrame);A Note on the "Internal" in ElementInternals
The name ElementInternals is a deliberate hint. This object is private. You should never expose this.#internals to the outside world. This is why I use a private field (the # syntax).
If you allow external scripts to access your internals, you’ve just created a "data attribute problem" with more steps. The whole point is that only the component knows its own mind. The outside world can only observe the results through the :state() pseudo-class.
The Verdict
The Custom State Set API is one of those "quality of life" improvements that fundamentally changes how you architect Web Components. It moves us away from the hacky "everything is a string attribute" era of the early web and into a world where components have true encapsulation.
It might feel like a small change—switching [data-busy] for :state(busy)—but it represents a shift toward building components that are more robust, less prone to external interference, and significantly easier to style. Next time you reach for setAttribute('loading', ''), try internals.states.add('loading') instead. Your DOM tree (and your future self) will thank you.


