Exposing Loading States to Consumer CSS: Designing a State Vocabulary as Public API
The moment a component calls states.add('loading') and a consumer writes my-panel:state(loading) { … }, that string has become API. It is not typed, not versioned, and not enforced by any tool — but a page’s appearance depends on it, so renaming it breaks that page exactly as removing a method would break a script. Component authors routinely treat state names as internal detail and discover the coupling at the worst possible moment.
The remedy is to design the vocabulary deliberately: a small set of names describing conditions rather than appearances, each documented with the precise trigger that sets and clears it, each paired with an event so application code has a path too. This deep dive belongs to Custom States & State-Driven Styling inside Styling, Theming & CSS Encapsulation.
Problem Statement: The Vocabulary That Leaked Implementation
class ResultsList extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `<slot></slot>`;
}
async refresh(query) {
const { states } = this.#internals;
// Names describing the IMPLEMENTATION, not the condition.
states.add('fetch-in-flight');
states.add('spinner-visible');
states.add('dimmed');
const rows = await this.#fetchRows(query);
states.delete('fetch-in-flight');
states.delete('spinner-visible');
states.delete('dimmed');
if (rows.length === 0) states.add('zero-results-v2');
}
async #fetchRows(query) { /* … */ return []; }
}
customElements.define('results-list', ResultsList);
Consumers wrote what they were given:
results-list:state(dimmed) { pointer-events: none; }
results-list:state(zero-results-v2)::after { content: 'No matches'; }
Six months later the team replaces fetch with a streaming reader, drops the dimming in favour of a skeleton, and renames the empty state. Every one of those changes is internal — and every one silently unstyles a consumer’s page. Worse, spinner-visible and dimmed were never separate conditions at all; they were two visual consequences of one condition, so consumers who used only one of them got inconsistent behaviour whenever the component’s own styling changed.
Root-Cause Analysis
The failure is not technical — every one of those states.add calls worked. It is an interface-design failure, and it has the same shape as any other leaky abstraction: the published names describe how the component currently behaves rather than what is currently true.
Three properties of custom states make the leak especially costly.
There is no deprecation mechanism. A removed method throws, a removed attribute can log a warning, but a state name that no longer gets added simply stops matching. The consumer’s rule remains valid CSS and quietly does nothing. Nothing in the platform will tell either party.
There is no type or schema. Nothing declares which states a component publishes. A consumer discovers them by reading source, reading documentation, or inspecting a running component — so undocumented internal states get used as though they were public, because they are indistinguishable from public ones.
The set is write-only from outside. Consumers cannot enumerate a component’s states; internals is private and CustomStateSet is not exposed on the element. So even a well-intentioned consumer cannot check whether a state still exists, and a build-time lint cannot verify a stylesheet against a component version.
Put together: every state name a component ever sets is, in practice, permanent public API unless it is documented as internal and nobody found it. The defence is to keep the published set small and stable, and to make internal conditions genuinely unreachable by expressing them as shadow-tree classes rather than states.
loading, spinner-visible, and dimmed together means three names must be kept in sync forever, and a consumer who picks the "wrong" one gets behaviour that drifts from the component's own styling. If two states always change together, they are one state. Publish that one, and drive the rest from it inside the shadow tree with ordinary classes.
There is a positive corollary worth stating. Because the vocabulary is small and stable by design, it becomes documentable in a way attributes rarely are: a table of five rows — name, meaning, set when, cleared when, paired event — is a complete specification of a component’s dynamic styling surface. That table is also the natural place to record which states are guaranteed mutually exclusive, which is information consumers need and cannot derive.
Production-Safe Pattern
The component below publishes exactly four states, derives all internal styling from them, and pairs every transition with an event. The published vocabulary is declared once as a frozen constant, which makes it greppable, testable, and hard to extend casually.
/** The component's PUBLIC state vocabulary. Adding to this is an API change. */
const PHASES = Object.freeze(['loading', 'ready', 'empty', 'error']);
class ResultsList extends HTMLElement {
#internals;
#controller = null;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; position: relative; }
/* Internal presentation is derived from the PUBLIC state, so the
spinner and the dimming are never separate published names. */
.skeleton { display: none; }
:host(:state(loading)) .skeleton { display: block; }
:host(:state(loading)) [part~="list"] { opacity: 0.5; pointer-events: none; }
.placeholder { display: none; }
:host(:state(empty)) .placeholder { display: block; }
:host(:state(error)) .placeholder { display: block; color: #842029; }
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; }
}
</style>
<div class="skeleton" role="status" aria-live="polite">Loading results…</div>
<p class="placeholder"><slot name="placeholder">No results.</slot></p>
<div part="list"><slot></slot></div>`;
}
connectedCallback() { this.#controller = new AbortController(); }
disconnectedCallback() {
this.#controller?.abort();
this.#controller = null;
this.#internals.states.clear();
}
/** Exactly one phase is active at a time — a documented guarantee. */
#setPhase(next) {
const { states } = this.#internals;
for (const phase of PHASES) {
if (phase !== next) states.delete(phase);
}
states.add(next);
// Every state change is also an event, so script does not have to poll.
this.dispatchEvent(new CustomEvent('phase-change', {
detail: { phase: next }, bubbles: true, composed: true
}));
}
get phase() {
return PHASES.find((phase) => this.#internals.states.has(phase)) ?? null;
}
async refresh(query) {
this.#setPhase('loading');
try {
const response = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: this.#controller.signal
});
const rows = await response.json();
this.#setPhase(rows.length === 0 ? 'empty' : 'ready');
return rows;
} catch (error) {
if (error.name === 'AbortError') return []; // teardown, not a failure
this.#setPhase('error');
throw error;
}
}
}
customElements.define('results-list', ResultsList);
Four decisions make this a stable interface. The vocabulary is a frozen array, so a new state cannot be added without editing the declaration that documentation and tests both read. The phase setter clears every other member, so the mutual-exclusivity guarantee is enforced rather than remembered. Internal presentation — skeleton, dimming, placeholder — is derived from the public state inside the shadow tree, so those visual choices can change freely. And a public phase getter gives script a synchronous read that does not depend on selector parsing.
The documentation that ships with it is the other half of the API:
| State | Meaning | Set when | Cleared when | Event |
|---|---|---|---|---|
loading |
A request is in flight | refresh() is called |
Any other phase is set | phase-change |
ready |
Results are displayed | A non-empty response resolves | Any other phase is set | phase-change |
empty |
The query returned nothing | An empty response resolves | Any other phase is set | phase-change |
error |
The request failed | The request rejects | Any other phase is set | phase-change |
Exactly one is active at a time. That guarantee is what lets a consumer write results-list:not(:state(ready)) and know what it means.
Verification
const list = document.querySelector('results-list');
const seen = [];
list.addEventListener('phase-change', (event) => seen.push(event.detail.phase));
await list.refresh('widgets');
// Mutual exclusivity is enforced, not documented-and-hoped.
const active = ['loading', 'ready', 'empty', 'error'].filter((s) => list.matches(`:state(${s})`));
console.assert(active.length === 1, `exactly one phase active, saw ${active.length}`);
// The event mirrors the state, so script never has to poll.
console.assert(seen[0] === 'loading', 'loading announced first');
console.assert(seen.at(-1) === list.phase, 'final event matches the final state');
// Internal presentation is derived, not published.
console.assert(!list.matches(':state(dimmed)'), 'no implementation-named state is exposed');
console.assert(!list.matches(':state(spinner-visible)'), 'presentation is internal');
// Teardown resets cleanly.
list.remove();
console.assert(list.phase === null, 'states cleared on disconnect');
A contract test is worth adding alongside these assertions: enumerate the published vocabulary from the frozen constant, and assert that the component never matches a state outside it. That converts “we agreed not to publish internal states” into something CI enforces — the same discipline applied to event payloads in contract testing custom event payloads.
When to Use vs When to Avoid
| Condition | Publish as a state? |
|---|---|
| A request is in flight | Yes — consumers reasonably want to restyle during it |
| The component has no content to show | Yes — a common styling hook |
| An operation failed | Yes |
| A skeleton placeholder is currently visible | No — a consequence of loading, not a condition |
| An internal animation is mid-flight | No — express it as a shadow-tree class |
| A consumer set a configuration option | No — that is an attribute, and should serialize |
| An experiment flag is on | No — versioned names never stop being used |
| A nested component reported something | Only after translating it into this component’s vocabulary |
The last row is the one that keeps a vocabulary stable over time. Forwarding an inner component’s states outward couples your public API to a dependency’s internals; translating them — listening for the inner event and setting your own state — keeps the coupling one-way and lets the dependency change.
Frequently Asked Questions
Can consumers discover which states a component publishes?
Not at runtime — ElementInternals is private and the set cannot be enumerated from outside. Documentation is the only discovery mechanism, which is why a published table of states, triggers, and paired events is part of the component rather than an extra.
What happens to consumer CSS when I rename a state?
Their rules stay valid and stop matching. There is no warning, no error, and no deprecation path in the platform, so the page simply loses styling. Treat a rename as a breaking change with a transition period in which both names are set.
Should every internal condition become a state?
No. Publish the conditions consumers have a legitimate reason to restyle, and express everything else as ordinary classes inside the shadow tree. A vocabulary of three to six names is usually right; a dozen means implementation detail has leaked.
Do I still need events if I publish states?
Yes. States are a styling channel only — script cannot observe them, so application code would have to poll. Dispatch an event on every transition and document the two as one contract.
Related
- Custom States & State-Driven Styling — the parent topic on
CustomStateSet. - Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Combining Custom States with Part Selectors — the other half of the consumer styling surface.
- Migrating from Dashed-Ident Custom States — what a state rename costs in practice.
- Contract Testing Custom Event Payloads — enforcing the paired event contract in CI.