loke.dev
Header image for 4 Ways to Use CSS Custom States to Simplify Your Web Component Styling

4 Ways to Use CSS Custom States to Simplify Your Web Component Styling

Why the new ElementInternals.states API is the end of the 'reflect-to-attribute' hack for styling custom elements based on internal logic.

· 4 min read

I spent three hours last week debugging a CSS selector that wouldn't fire because I forgot to manually reflect a boolean property to an HTML attribute. It’s a classic "Web Component Tax"—if you want to style based on internal state, you’re usually forced to pollute your DOM with dummy attributes just so your CSS can find them.

For years, the "reflect-to-attribute" pattern was the only way to get internal logic (like isLoading) into our stylesheets. It felt dirty because it exposed internal implementation details as public-facing attributes. Thankfully, the ElementInternals.states API is finally here to save us from our own clutter. It allows us to define "Custom States" that behave like pseudo-classes—think :checked or :hover, but entirely defined by you.

Here are four ways to use CSS Custom States to clean up your components and ditch the attribute hack.

1. The Binary Toggle (The Loading Spinner)

We’ve all built a button that shows a spinner when it’s busy. The old way involved this.setAttribute('loading', '') and then styling my-button[loading]. This is annoying because the user can technically type document.querySelector('my-button').removeAttribute('loading') and break your UI state without changing the actual logic.

With Custom States, you keep that state private.

class LoadingButton extends HTMLElement {
  constructor() {
    super();
    this._internals = this.attachInternals();
  }

  async handleClick() {
    this._internals.states.add('loading');
    await fetch('/api/data');
    this._internals.states.delete('loading');
  }
}
customElements.define('loading-button', LoadingButton);

In your CSS, you target this using the :state() pseudo-class:

loading-button:state(loading) .spinner {
  display: block;
}

loading-button:state(loading) .label {
  opacity: 0.5;
  pointer-events: none;
}

It’s cleaner, it’s encapsulated, and it doesn't show up as a gross attribute in the Elements panel unless you want it to.

2. Managing Form Validation "Moods"

Form components are the worst offenders for attribute bloat. You usually need invalid, touched, dirty, and valid. Instead of juggling four different attributes, you can use ElementInternals.states as a Set to toggle these on the fly.

Because internals.states is literally an instance of CustomStateSet (which acts like a Set), adding and removing states is a one-liner.

set valid(value) {
  if (value) {
    this._internals.states.add('valid');
    this._internals.states.delete('invalid');
  } else {
    this._internals.states.add('invalid');
    this._internals.states.delete('valid');
  }
}

Now your CSS reads like a sentence:

custom-input:state(invalid) {
  border-color: var(--error-red);
}

custom-input:state(valid) {
  border-color: var(--success-green);
}

A quick heads-up: In earlier versions of the spec, you had to prefix these with double dashes (like :state(--invalid)), but the modern standard has moved toward the cleaner :state(invalid) syntax. Check your browser targets, but most modern engines are aligned on the prefix-less version now.

3. Creating "Internal-Only" Interactive States

Sometimes you want a component to have a state that isn't strictly "active" or "hovered" but is triggered by a specific sequence of events—like a "drag-over" state in a custom file uploader.

Previously, if you used a class like .is-dragging, a global CSS file could accidentally interfere with it. If you used an attribute, it was public. Custom states stay tucked away inside the ElementInternals object, which is only accessible to your class.

// Inside your dragover handler
this.onDragOver = () => {
  this._internals.states.add('dragging');
};

// Inside your dragleave handler
this.onDragLeave = () => {
  this._internals.states.delete('dragging');
};

This creates a "Private Styling API." You can expose these states to the outside world if you want, but by default, they are your component's business and nobody else's.

4. Multi-Stage Components (The Stepper)

If you’re building a multi-step wizard or a progress bar, you might have states like step-1, step-2, and complete. Reflecting these to a data-step="1" attribute is common, but it requires weird CSS selectors like [data-step="1"].

With Custom States, you can treat your component as a state machine.

updateProgress(step) {
  // Clear previous steps
  this._internals.states.forEach(s => {
    if (s.startsWith('step-')) this._internals.states.delete(s);
  });
  
  // Add current step
  this._internals.states.add(`step-${step}`);
}
/* Styling the component based on the current step */
my-wizard:state(step-1) .back-button {
  visibility: hidden;
}

my-wizard:state(step-3) .next-button {
  background: gold;
}

Why should you care? (The "Why")

The real win here isn't just "shorter code." It's Performance and Encapsulation.

1. Performance: When you change an attribute (via setAttribute), the browser has to go through a full attribute mutation cycle. It might trigger attributeChangedCallback. Custom states are optimized for the CSS engine.
2. Cleaner DOM: Your DevTools won't look like a Christmas tree of active="true", collapsed="false", is-ready="true".
3. Semantic Separation: Attributes should be used for configuration (like src, href, type). States should be used for, well, *state*.

The ElementInternals API is one of the best things to happen to Web Components since Shadow DOM. It finally treats Custom Elements as first-class citizens that can have their own "native" behaviors without relying on the same hacks we used in 2014. If you aren't using states yet, start by refactoring just one "loading" or "active" attribute—your codebase will thank you.