Exposing Custom Validity States to CSS

A custom control can compute that it is invalid, store a flag, even render a red border by hand — and still have :invalid refuse to match it. The browser’s validity pseudo-classes are not fooled by attributes or internal state; they react to exactly one source of truth. This deep-dive reproduces that mismatch, traces it to the spec, and shows the complete fix using ElementInternals.setValidity() for native validity pseudo-classes plus internals.states for custom :state() styling.

This page belongs to the Form-Associated Custom Elements topic, within Core Architecture & Lifecycle Management.

Minimal Reproducible Example: :invalid That Never Matches

The control below knows perfectly well that it is empty and required. It sets a data-invalid attribute and the author wrote a :invalid rule. Neither the attribute approach nor the pseudo-class produces the intended outline, and form.checkValidity() reports the form as valid.

class PinInput extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  #value = '';

  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host(:invalid) { outline: 2px solid red; }  /* never matches */
      </style>
      <input inputmode="numeric" />`;
    this.shadowRoot.querySelector('input').addEventListener('input', (e) => {
      this.#value = e.target.value;
      // ❌ Author tries to signal invalidity with an attribute.
      this.toggleAttribute('data-invalid', this.#value.length !== 4);
    });
  }
}
customElements.define('pin-input', PinInput);
<form>
  <pin-input name="pin" required></pin-input>
  <button>Submit</button>
</form>

Submitting the empty form succeeds. form.checkValidity() returns true. The :host(:invalid) rule is dead code.

Root-Cause Analysis

What CSS validity and state pseudo-classes actually read Attributes and private fields are ignored; only ElementInternals validity flags and the custom state set feed the matching pseudo-classes. data-invalid attribute ignored by CSS validity setValidity(flags, msg) ElementInternals states.add('--name') CustomStateSet Style engine reads internals only recomputes on change :invalid / :valid matches validity flags :user-invalid after interaction :state(--name) matches custom state set

Per the WHATWG HTML Standard, the :invalid, :valid, :user-invalid, and :user-valid pseudo-classes match a form-associated custom element strictly according to the validity states stored on its ElementInternals object. Those flags are mutated by one method only: ElementInternals.setValidity(). The browser never inspects custom attributes (data-invalid), private fields, or any required attribute you place on the host — required on a custom element is just an attribute with no built-in meaning unless your code reads it and calls setValidity() accordingly.

Because the MRE never calls setValidity(), the element’s validity flag set stays empty, which the spec defines as valid. Consequently:

The same principle governs custom non-validity styling. The :state(name) pseudo-class matches only identifiers present in the element’s CustomStateSet, reached through internals.states. Toggling a data-* attribute or a class does not populate that set, so :state() rules likewise stay inert until you call internals.states.add().

There is also a meaningful distinction between :invalid and :user-invalid worth understanding, because it explains a frequent “it lights up red immediately” complaint. :invalid matches the instant the validity flags say the control is invalid — including on first paint, before the user has touched anything. :user-invalid matches only after the user has interacted with the control and attempted submission (or blurred a control the browser considers dirtied), mirroring the heuristic native inputs use. Neither is driven by attributes; both read the same ElementInternals validity flags. Choosing :user-invalid for error presentation avoids shouting at users about fields they have not yet reached, while :invalid remains useful for non-visual logic or deliberate always-on indicators.

Production-Safe Fix

The corrected control drives validity exclusively through setValidity(), anchors the validation message to a focusable element, distinguishes “invalid” from “the user has interacted” via :user-invalid, and adds a custom --complete state once the PIN is filled so CSS can react without overloading validity.

class PinInput extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['required'];

  #internals;
  #input;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
    shadow.innerHTML = `
      <style>
        :host { display: inline-block; }
        /* Native validity pseudo-classes now react to ElementInternals. */
        :host(:invalid) input { outline: 2px solid #ffa83e; }
        :host(:user-invalid) input { outline: 2px solid #ff5a5a; }
        /* Custom state, independent of validity. */
        :host(:state(--complete)) input { outline: 2px solid #29c587; }
      </style>
      <input inputmode="numeric" maxlength="4" part="field" />`;
    this.#input = shadow.querySelector('input');
    this.#input.addEventListener('input', () => {
      this.value = this.#input.value;
    });
  }

  connectedCallback() { this.#refresh(); }

  get value() { return this.#input.value; }
  set value(v) {
    this.#input.value = v ?? '';
    this.#internals.setFormValue(this.#input.value || null);
    this.#refresh();
  }

  #refresh() {
    const v = this.#input.value;
    const required = this.hasAttribute('required');

    // Drive native validity through ElementInternals only.
    if (required && v.length === 0) {
      this.#internals.setValidity(
        { valueMissing: true }, 'Enter your 4-digit PIN.', this.#input);
    } else if (v.length > 0 && v.length !== 4) {
      this.#internals.setValidity(
        { tooShort: true }, 'PIN must be exactly 4 digits.', this.#input);
    } else {
      this.#internals.setValidity({}); // clear → valid
    }

    // Surface a custom state for non-validity styling.
    if (v.length === 4) this.#internals.states.add('--complete');
    else this.#internals.states.delete('--complete');
  }

  // Keep validity correct across reset/restore.
  formResetCallback() { this.value = ''; }
  formStateRestoreCallback(state) { this.value = state ?? ''; }
}
customElements.define('pin-input', PinInput);

Three points make this work where the MRE failed: validity is now expressed through setValidity() so :invalid and :user-invalid match; the third argument to setValidity() is the validation anchor, the element the browser focuses and points its bubble at when reportValidity() runs; and the custom --complete state lives in internals.states, keeping presentation concerns out of the validity machinery.

Verification

Confirm both the native flags and the custom state from script and from the rendered styles.

const pin = document.querySelector('pin-input');

// Native validity now reflects ElementInternals.
console.log(pin.matches(':invalid'));            // → true when empty + required
console.log(pin.validity.valueMissing);          // → true
console.log(pin.validationMessage);              // → "Enter your 4-digit PIN."
console.log(pin.checkValidity());                // → false (was true before fix)

pin.value = '1234';
console.log(pin.matches(':invalid'));            // → false
console.log(pin.matches(':state(--complete)')); // → true

In DevTools, the Elements panel shows the element matching :invalid in the styles sidebar, and toggling :user-invalid via the :hov flyout (or after a real blur/submit) lets you confirm the user-interaction variant. pin.reportValidity() should focus the inner <input> and display the message anchored to it, proving the anchor argument resolved.

When to Use / When to Avoid

Need Use
Block native form submission when invalid setValidity({ valueMissing: true }, msg, anchor).
Style only after the user has interacted :user-invalid / :user-valid (no extra code).
Style immediately, even untouched :invalid / :valid.
Custom, non-validity visual state (open, loading, selected) internals.states.add('--name') + :state(--name).
A free-form error message that is not a standard constraint setValidity({ customError: true }, msg, anchor).
Clear all errors setValidity({}) — never leave stale flags.
Pre-:state() engines (older Chromium) Fall back to the legacy :--name syntax behind feature detection.

Debugging Pitfall: Passing a non-empty message to setValidity() while clearing the flags (e.g. setValidity({}, 'oops')) throws a TypeError — a message is only allowed alongside at least one truthy flag. When clearing validity, call setValidity({}) with no message. Conversely, setting a flag with an empty message also throws, so always pair a flag with a human-readable string.