DEV Community

korial29
korial29

Posted on

Building Form-Associated Custom Elements with ElementInternals

Web components have been able to fully participate in native HTML forms since 2023 — submit their value with FormData, trigger :invalid, reset alongside the <form>, the works. The API that makes it possible, ElementInternals, is now supported in Chromium, Firefox, and Safari 16+.

The catch: nobody tells you it's about 40 lines of repetitive plumbing per component. This post walks through what that plumbing looks like, then shows a small reusable mixin (form-control-mixin) that gets rid of it.

What "form-associated" actually buys you

A form-associated custom element can:

  • submit a value as part of the form's FormData, under its name attribute
  • expose validity through the same ValidityState interface native inputs use (valueMissing, tooShort, patternMismatch, …)
  • react to the form being reset, its ancestor <fieldset> being disabled, or the browser restoring state (bfcache, autofill)

All of that lives behind HTMLElement.attachInternals(). Here's the minimum viable version, no library, no framework:

class RequiredInput extends HTMLElement {
  static formAssociated = true;

  #internals = this.attachInternals();

  connectedCallback() {
    this.innerHTML = '<input>';
    this.querySelector('input').addEventListener('input', (e) => {
      this.#internals.setFormValue(e.target.value);
      this.#internals.setValidity(
        e.target.value ? {} : { valueMissing: true },
        'Please fill out this field.',
      );
    });
  }

  formResetCallback() {
    this.querySelector('input').value = '';
  }
}

customElements.define('required-input', RequiredInput);
Enter fullscreen mode Exit fullscreen mode

That's one validator, on one field, with no reset-syncing of validity, no disabled handling, no styling hook for "the user actually touched this field." Add a second validator, or a second component, and you're rewriting the same setFormValue/setValidity dance every time.

The reusable version

form-control-mixin wraps that plumbing in a mixin you apply to any class extending HTMLElement — a plain custom element, a LitElement, a Stencil or FAST base class:

npm install form-control-mixin
Enter fullscreen mode Exit fullscreen mode
import { FormControlMixin, requiredValidator, minLengthValidator } from 'form-control-mixin';

class MyInput extends FormControlMixin(HTMLElement, {
  validators: [requiredValidator, minLengthValidator(3)],
}) {
  value = '';

  connectedCallback() {
    this.requestValidation();
  }

  formResetCallback() {
    this.value = '';
    this.requestValidation();
  }
}

customElements.define('my-input', MyInput);
Enter fullscreen mode Exit fullscreen mode
<form>
  <my-input name="nickname"></my-input>
  <button>Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

new FormData(form).get('nickname'), :invalid, reportValidity(), the browser's native validation bubble — all of it works, because the mixin is just calling the same ElementInternals methods you'd call by hand, in the right order, every time requestValidation() runs.

Validators are plain objects — { key, message, isValid(host, value) } — checked in order, first failure wins. Nine ship built in: requiredValidator, minLengthValidator, maxLengthValidator, patternValidator, emailValidator, urlValidator, minValidator, maxValidator, stepValidator, mirroring the native required/minlength/maxlength/pattern/type="email"/type="url"/min/max/step semantics respectively. Writing your own — an async username-availability check, a cross-field comparison — is the same three-property shape.

Styling interaction state without attribute soup

This is the part that usually gets bolted on later as a pile of reflect: true properties and manual classList.toggle() calls. ElementInternals has a states property — a CustomStateSet — built exactly for this, and the mixin keeps it in sync automatically:

my-input:state(invalid) {
  outline: 2px solid crimson;
}

/* only complain once the user has actually left the field */
my-input:state(touched):state(invalid) {
  background: #fdeced;
}

my-input:state(dirty) {
  border-color: #6355ff;
}
Enter fullscreen mode Exit fullscreen mode

valid/invalid mirror validity.valid. touched flips on the first focusout. dirty flips once the value diverges from whatever it was on the first requestValidation() call. No attributes, no re-renders triggered by styling concerns — just CSS reacting to state the component already has to track anyway.

Why cross-browser testing isn't optional here

If you reach for jsdom to test any of this — don't. jsdom doesn't correctly implement ElementInternals: form association, setFormValue, and the validity plumbing either no-op or throw depending on the version. Tests will pass while the actual behavior is broken in a real browser, which is worse than having no tests at all.

form-control-mixin's own suite runs against real Chromium, Firefox, and WebKit via @web/test-runner + Playwright — the same engines your users actually run.

Why this exists

An attempt at solving this generically already exists — @open-wc/form-control — but it hasn't had a release in about three years and never settled on a finished API. Bigger component libraries (Shoelace, Ionic, FAST) solved the same problem, but only inside their own codebase, not as something you can install if you're building your own design system from scratch.

form-control-mixin is ~3 KB gzipped, has zero runtime dependencies, ships its own TypeScript types, and doesn't assume Lit or any other renderer.

MIT licensed. Issues, PRs, and "here's a validator I wish existed" requests are all welcome.

Top comments (0)