Migrating from Dashed-Ident Custom States: From :–loading to :state(loading)

CustomStateSet shipped twice. The first version, in Chromium 90, required every state name to be a dashed ident — a string beginning with two hyphens — and matched it with a pseudo-class of the same shape: states.add('--loading') paired with :--loading. The second, standardised version uses a plain identifier and an explicit functional pseudo-class: states.add('loading') paired with :state(loading).

Both shapes were live in shipping Chromium for four years, which means real component libraries, real design-system stylesheets, and real application CSS were written against the old one. A migration is therefore not a find-and-replace on one repository; it is a coordinated change across a component’s internals, its own stylesheet, and every consumer stylesheet the team does not control. This deep dive belongs to Custom States & State-Driven Styling inside Styling, Theming & CSS Encapsulation.

Problem Statement: The Component That Works in One Browser

class SyncBadge extends HTMLElement {
  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 999px; }
        /* Legacy syntax: works only on Chromium 90–124. */
        :host(:--syncing) { background: #fff3cd; }
        :host(:--offline) { background: #f8d7da; }
      </style>
      <slot></slot>`;
  }

  connectedCallback() {
    // Legacy naming: the leading dashes are part of the state name.
    this.#internals.states.add('--syncing');
  }
}
customElements.define('sync-badge', SyncBadge);

On Chromium 124 this renders correctly. On Chromium 125 and later the selector :--syncing no longer parses as a custom state, so the rule is dropped and the badge loses its colour. On Firefox and Safari the component never worked at all — those engines only ever implemented the standard form, so both the add('--syncing') call and the selector were inert. The result is a component that looks fine in the browser it was developed in and unstyled everywhere else, with no error in any console.

Where each syntax works across engines and versions The dashed-ident form worked only in Chromium 90 through 124, while the standard state form works in Chromium 125 and later, Firefox 126 and later, and Safari 17.4 and later. Support windows for the two syntaxes Chromium :--name (90–124) :state(name) (125+) Firefox no custom states :state(name) (126+) Safari no custom states :state(name) (17.4+) The overlap where BOTH syntaxes work is empty: no engine version accepts the dashed form and the standard form. So the migration must set BOTH state names and ship BOTH selectors during the transition window.

Root-Cause Analysis

The change was deliberate and was made for a syntactic collision. A dashed ident is already meaningful in CSS: --foo is the syntax for a custom property, and CSS Values Level 5 reserves the -- prefix for author-defined names generally. Using the same prefix for a pseudo-class created a parsing ambiguity with proposed features — notably custom selectors — and gave authors no way to tell at a glance whether :--loading was a state, a future custom selector, or a typo.

The CSS Working Group resolved it by moving custom states into an explicit functional form, :state(), which is unambiguous, greppable, and consistent with :is(), :where(), :has(), and :not(). The state name then became an ordinary identifier, so the leading dashes disappeared from both sides.

Three consequences shape the migration.

The two forms are disjoint, not overlapping. No engine version accepts both. Chromium 125 removed the old parsing when it added the new; Firefox and Safari only ever had the new. There is therefore no single string that works everywhere, and the transition requires shipping both.

State names and selectors must move together. states.add('--syncing') makes :state(--syncing) match, not :state(syncing) — because the name really is the string you added, dashes included. A team that updates only the stylesheet gets no match; a team that updates only the JavaScript gets no match. This is the single most common way the migration goes wrong.

Consumer stylesheets are outside your repository. A design system that documented my-el:--loading as a theming hook has consumers with that selector in their own CSS. Removing the old state name silently unstyles their pages. This is an API break and needs a deprecation window, not a patch release.

Debugging Pitfall — "it works in my browser". Because the legacy form worked in exactly one engine, a component using it passes local development, passes a Chromium-only CI run, and fails silently in Firefox and Safari with no console output — the selector is simply dropped as unrecognised. Any component test suite that only runs headless Chromium is structurally unable to catch this. Run the styling assertions in at least two engines, as visual regression testing shadow DOM sets out.
What changes on each side of the migration The state name loses its leading dashes in the script, the component stylesheet moves to the functional pseudo-class, and consumer stylesheets need a deprecation window because they live outside the repository. 1. Component script states.add('--syncing') states.add('syncing') set BOTH during the transition window you control this 2. Component stylesheet :host(:--syncing) :host(:state(syncing)) separate rules, never a shared selector list you control this 3. Consumer stylesheets my-el:--syncing my-el:state(syncing) outside your repository removing the old name is a breaking change needs a major release

Production-Safe Migration

The transitional component sets both names and ships both selectors. Because no engine understands both, the duplication costs one extra string per state and one extra rule per declaration block, and the invalid rule is simply dropped by whichever engine does not recognise it.

/** Adds or removes a state under BOTH the standard and legacy names. */
const setState = (states, name, on) => {
  const legacy = `--${name}`;
  if (on) {
    states.add(name);
    // Older Chromium rejects nothing here; newer engines just carry a second string.
    states.add(legacy);
  } else {
    states.delete(name);
    states.delete(legacy);
  }
};

class SyncBadge extends HTMLElement {
  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 999px; }

        /* Standard form — Chromium 125+, Firefox 126+, Safari 17.4+. */
        :host(:state(syncing)) { background: #fff3cd; color: #664d03; }
        :host(:state(offline)) { background: #f8d7da; color: #842029; }

        /* Legacy form — Chromium 90–124. MUST be a separate rule: an unknown
           selector in a comma-separated list invalidates the whole list. */
        :host(:--syncing) { background: #fff3cd; color: #664d03; }
        :host(:--offline) { background: #f8d7da; color: #842029; }
      </style>
      <slot></slot>`;
  }

  set syncing(on) { setState(this.#internals.states, 'syncing', Boolean(on)); }
  get syncing() { return this.#internals.states.has('syncing'); }

  set offline(on) { setState(this.#internals.states, 'offline', Boolean(on)); }
  get offline() { return this.#internals.states.has('offline'); }
}
customElements.define('sync-badge', SyncBadge);

The comment on the legacy rules is the part most migrations get wrong. CSS error handling discards an entire selector list containing an unparseable selector, so writing :host(:state(syncing)), :host(:--syncing) { … } produces no styling on either engine — the modern one chokes on :--syncing, the legacy one chokes on :state(). They must be separate rules with duplicated declarations, or the duplication must be generated by a build step.

For consumers, publish the new selector in the documentation immediately, keep the legacy state name for one major version, and emit a development-mode warning when a legacy name is observed in use. When the deprecation window closes, delete the legacy branch of setState and the legacy rules together — in the same commit, so the two halves cannot drift.

Verification

const badge = document.querySelector('sync-badge');
badge.syncing = true;

// Both names are present during the transition window.
console.assert(badge.matches(':state(syncing)'), 'standard state set');

// Feature-detect which syntax this engine understands.
const supportsStandard = CSS.supports('selector(:state(x))');
const supportsLegacy = CSS.supports('selector(:--x)');
console.log({ supportsStandard, supportsLegacy });
console.assert(supportsStandard !== supportsLegacy, 'exactly one syntax is live per engine');

// The styling consequence, which is what actually matters.
const bg = getComputedStyle(badge).backgroundColor;
console.assert(bg !== 'rgba(0, 0, 0, 0)', 'a state rule applied in this engine');

// And it clears cleanly on both names.
badge.syncing = false;
console.assert(!badge.matches(':state(syncing)'), 'standard name cleared');
console.assert(getComputedStyle(badge).backgroundColor !== bg, 'styling reverted');

CSS.supports('selector(...)') is the reliable detector and is worth keeping in the codebase past the migration, because it turns “which syntax does this engine want” from tribal knowledge into a runtime fact. In DevTools, the Styles pane greys out rules whose selector did not parse, which makes the legacy block visibly inert on a modern engine — a good confirmation that the duplication is doing what you think.

When to Use vs When to Avoid

Situation Approach
New component, modern support floor Standard :state() only
Existing component, Chromium-only internal app Migrate outright; no transition needed
Published library with external consumers Dual-write states, dual-ship rules, deprecate over one major
Legacy rules cannot be duplicated by hand Generate the pair in the build step
Consumer CSS you cannot change at all Keep the legacy state name indefinitely; document it as frozen
Support floor already excludes Chromium 124 Delete the legacy branch now

The fourth row deserves a note. If the stylesheet is authored in a preprocessor or generated from tokens, a small mixin that emits both rules from one source removes the risk of the two copies diverging. Hand-maintained duplication is workable for three or four states and error-prone beyond that.

A four-release migration plan for a published component library Ship dual states and dual rules, document only the standard selector, warn on legacy usage in development, then remove both legacy halves together in a major release. Deprecation window: the two legacy halves must be deleted together minor minor minor major 1. Dual-write add both names ship both rules nothing breaks for anyone 2. Redocument only :state() appears in the docs legacy marked deprecated 3. Warn dev-mode console notice on legacy use consumers migrate at their own pace 4. Remove delete legacy name and legacy rules same commit so they cannot drift

Frequently Asked Questions

Can I put both selectors in one comma-separated list?

No. CSS discards an entire selector list if any selector in it fails to parse, so a combined list produces no styling on either engine. Write two separate rules with duplicated declarations, or generate them.

Does adding a legacy state name break modern engines?

No. states.add('--syncing') on a modern engine simply stores a string that happens to start with hyphens; it matches :state(--syncing) and nothing else in your stylesheet. It costs one entry in the set.

How do I detect which syntax the engine supports?

CSS.supports('selector(:state(x))') returns true on engines with the standard form, and CSS.supports('selector(:--x)') on the legacy Chromium versions. Exactly one is true on any given engine.

Is removing the legacy state name a breaking change?

Yes, if the states are documented as a theming hook. Consumer stylesheets referencing my-el:--loading stop matching, and their pages lose styling with no error. Treat it as a major-version change with a deprecation window.