Step 08

Form-associated elements

Every component this series has built so far is self-contained: it renders, it reacts, and nothing outside it needs to know what is inside. A form control is the opposite, because the browser collects it: it has a name and a value the form gathers on submit, a validity state that can stop that submit, and a set of behaviors — reset, disable, restore — the form drives rather than your code. A custom element gets none of that by default, and putting an <input> inside it does not help, because a control in your shadow tree has no form owner at all. This step covers the opt-in that fixes it: static formAssociated and the form half of ElementInternals, which together let the browser treat your element as one of its own controls — and the parts of being a control that no API hands you.

Learning Objectives

  • Make a custom element the form actually submits
  • Report a validity the browser enforces, not one you police yourself
  • Respond to reset, disable, and state restoration
  • Know what ElementInternals gives you — and what it does not

A custom element is not a form control

Start with what actually happens, because it is less dramatic than a bug and easier to miss. Put <my-field name="email"> inside a <form>, give it a beautiful editing experience, and mark it required. Nothing errors. The form does not complain. It also does not collect anything: the element is not on the form's list of controls, so FormData has no entry for it, the required attribute is an attribute nobody reads, and pressing Submit with the field untouched submits the form happily, with the field simply absent.

// <form id="signup"> // <my-field name="email" required></my-field> // </form> const form = document.getElementById('signup'); new FormData(form).get('email'); // null — the element contributes nothing form.elements.email; // undefined — it is not a control form.checkValidity(); // true — there is nothing to be invalid

Nesting a real control inside does not rescue it either. A form control's owner is found in its own tree, so an <input> in your shadow root has no form owner — not the outer form, not any form — and is never submitted with it. That is not an oversight; it is encapsulation doing exactly what step 04 said it does. The boundary that keeps the page out of your component keeps your component out of the page's form.

Before form association existed, the workaround was a hidden <input> in the light DOM shadowing the real UI, written to by hand on every change. It submits, which is the only thing it does well. Reset clears the hidden input and leaves your visible UI showing the old value. A disabled ancestor <fieldset> greys out nothing, because there is nothing of yours for it to reach. Validation either points at an invisible element or is reimplemented from scratch, with your own bubble, your own submit interception, and your own bug the day somebody submits by pressing Enter. Every one of those is a symptom of the same thing: the browser does not know your element exists. The rest of this step is about telling it.

Opting in

Two things, and both are required. A static field on the class declares that instances of this element are form controls, and attachInternals() returns the object through which the element talks to the form.

class EmailField extends HTMLElement { static formAssociated = true; // must be a static field on the class #internals = this.attachInternals(); // one per element, ever }

formAssociated is read once, when customElements.define() runs, and it is read off the constructor — so it has to be a static field or a static getter, not something you assign to an instance later. Setting it changes the element itself, before you write another line: it becomes labelable, so <label for> associates with it; it supports disabled; it appears in form.elements; and it inherits the disabled state of an ancestor <fieldset>.

attachInternals() is the same call step 03 used for custom states, and the same object — there is only ever one per element. What formAssociated unlocks is its form half: form, setFormValue(), setValidity(), validity, labels, and the rest.

⚠️ Both halves throw if you get this wrong

attachInternals() throws a NotSupportedError on the second call for the same element, which is why it belongs in a field initializer or the constructor and nowhere else — call it in connectedCallback and the first time the element is moved and reconnected, it throws. Independently, every form-related member of ElementInternals throws NotSupportedError unless formAssociated is true. Forget the flag and the failure arrives at your first setFormValue(), several steps away from the line that caused it.

Submitting a value

One call does it. setFormValue() tells the form what this control's value currently is, and from that moment the element has an entry in FormData and in the submitted body like any native control.

#sync() { this.#internals.setFormValue(this.#input.value); }

Two things about that line matter more than its length. The first is that the browser never asks: it stores whatever you last handed it, so #sync() has to run on every change that alters the value — input events, programmatic property sets, anything. Miss one and the form submits a stale value with no warning, because from the form's point of view nothing is wrong. The second is that the element still needs a name attribute, exactly like a native control. No name, no entry; the value is set and simply never submitted.

The argument does not have to be a string. Pass a File for an upload control, or null to contribute nothing at all — the way an unchecked checkbox contributes nothing. Pass a FormData when one control owns several entries: a date-range picker that submits start and end, or a card field that submits a number and an expiry. Those entries carry their own names, taken from the FormData rather than from the element's name. There is also an optional second argument, a state value, which the browser keeps separately and hands back to formStateRestoreCallback() — useful when what you need to restore your UI is not the same as what you submit.

Validity the browser respects

This is the part that is genuinely hard to fake, and the reason form association is worth the ceremony. setValidity() does not record a note for your own code to check later; it puts the element into the browser's constraint validation machinery, next to every <input required> on the page.

Note what it does not do, because this is where the required attribute from the opening section finally gets its answer. Form association never teaches the browser what your attributes mean. required is still an attribute nobody reads on your behalf — the sample below reads it with hasAttribute() and decides for itself — and the same goes for pattern, minlength, and every other constraint name you choose to honor. What you get from the platform is the machinery that acts on the verdict, not the rule that reaches it.

if (this.hasAttribute('required') && value === '') { this.#internals.setValidity({ valueMissing: true }, 'An email address is required.', this.#input); } else { this.#internals.setValidity({}); }

The three arguments each do one job. The first is a set of validity flags — the same names a native control reports: valueMissing, typeMismatch, patternMismatch, rangeOverflow, customError, and the rest. Any flag you leave out is false, so setValidity({}) is how you say "valid" and is the branch people forget: a control that only ever sets flags never becomes valid again. The second is the message the browser will display, and it is required whenever any flag is true — passing flags without a message is a TypeError, not a silently empty bubble. The third is an anchor: an element inside your shadow tree that the browser scrolls to and points the bubble at. Omit it and submission is still blocked, but the browser has no node inside your component to aim at.

What you get in return is everything constraint validation already does, without writing any of it. Submission stops before the submit event fires, so there is nothing to intercept and no handler to remember to guard. :invalid and :valid match your host element in CSS. form.checkValidity() counts your element in, and form.reportValidity() shows your message.

One piece of that surface is not free, and it catches people who assume the element is now a control in every respect. The validity state is real, but the validity properties are not there: element.validity, element.validationMessage, element.willValidate, and element.form are all undefined on a form-associated custom element unless you put them there. They live on ElementInternals, and ElementInternals is deliberately private — the point of putting the form API on a separate object is that the page cannot reach it. Forwarding the ones you want is a handful of one-line getters, and worth writing, because that is what makes your element inspectable by code that expects a native control.

get form() { return this.#internals.form; } get validity() { return this.#internals.validity; } get validationMessage() { return this.#internals.validationMessage; }

The submit handler never runs — that is the test

If an invalid element still reaches your submit listener, the element is not really participating and something above is wrong: the static flag is missing, the value was never set, or your validity was computed but never handed to setValidity(). Validation you enforce in a submit handler is a policy; validation the browser enforces is a state, and states survive the second entry point you forgot about — a requestSubmit() call, a submit button in a different part of the page, Enter in another field.

One consequence worth knowing before it surprises you: a disabled control is barred from constraint validation entirely. An element that is disabled, or sits inside a disabled <fieldset>, has internals.willValidate read false, submits no value, and cannot block the form no matter what flags are set on it. That is native behavior, and now it is yours too.

The form lifecycle callbacks

Being a control is not only about submission. Forms do things to their controls, and a form-associated custom element gets told about each of them through four callbacks that sit alongside the lifecycle set from step 05.

  • formAssociatedCallback(form) — the element joined a form, or left one (null)
  • formResetCallback() — the form was reset; clearing yourself is your job
  • formDisabledCallback(disabled) — the element or an ancestor <fieldset> was disabled or re-enabled
  • formStateRestoreCallback(state, mode) — the browser is restoring a value on back-navigation ("restore") or autofill ("autocomplete")
formResetCallback() { this.#input.value = ''; this.#sync(); } formDisabledCallback(disabled) { this.#input.disabled = disabled; }

formResetCallback() is a notification, not a reset. The browser restores its own controls to their default values and then tells you it happened; if your callback does nothing, the form clears every native field around you and your element sits there still showing the old value. Note the second line, too — clearing the visible input does not clear the value the form holds, so the callback has to run the same #sync() as any other change. That is the argument for putting value and validity in one function: there is only one place that can get out of step, and everything calls it.

formDisabledCallback() is the one that would be genuinely painful to do yourself. Disabled state is inherited from any ancestor <fieldset>, at any depth, and it changes whenever that fieldset does — there is no attribute on your element to observe and no event to listen for. The browser walks it for you and calls the method.

Demo: a custom element inside a real form

One <email-field> in an ordinary <form>, with an ordinary submit button. Press Submit with the field empty and watch what happens: the browser's own validation bubble appears, and the message in it is the string passed to setValidity(). The submit handler below never runs — the output block still reads "(nothing submitted yet)". Nothing in the page checked anything.

Then type a valid address and submit: the output shows an "email" entry holding what you typed, read straight out of new FormData(form), which is the proof that the element is a real entry rather than something the page copied across by hand. Reset clears it, through formResetCallback(). The red border while the field is invalid comes from :host(:state(invalid))step 03's custom states, not a class the page can set.

The last button is the one to watch closely. It does not call formDisabledCallback(); it toggles disabled on the ancestor <fieldset> and nothing else. The inner input greys out anyway, because the browser propagated the state and invoked the callback. Submit while it is disabled and the form goes through with no email entry at all — the barred-from-validation rule from the previous section, visible.

What you still owe the user

Form association makes the element a control to the form. It does not make it a control to the person using it, and the gap is easy to ship without noticing, because the demo above submits correctly while still being worse than an <input> in several ways.

Focus does not route itself. The host is labelable, so <label for> associates and internals.labels lists the labels — but clicking that label calls focus() on your host, and a host that is not focusable passes nothing inward, so the focus lands nowhere. The click itself is not lost — label activation still dispatches a click event at the host, so a listener there fires exactly as you would expect. It is the caret that never arrives, which is the half users notice and the half tests do not. Tabbing still reaches the control, because sequential focus navigation walks into open shadow roots on its own; it is every deliberate focus that lands nowhere, including your own element.focus(). Pass delegatesFocus: true when you attach the shadow root, or manage tabindex and override focus() yourself.

// Focus has to be routed deliberately const root = this.attachShadow({ mode: 'open', delegatesFocus: true }); // A default the page can still override with a real ARIA attribute this.#internals.ariaLabel = 'Email address';

The accessible name is not automatic either. internals.ariaLabel, internals.role and their siblings set the element's defaults in the accessibility tree — deliberately, so that an author who writes aria-label on your element in their markup still wins. That is the right precedence, and it also means your defaults are only defaults: set none and there is no name to fall back to. The demo above has the bug on purpose. Its <label for> associates with the host, but the node a screen reader actually lands on is the <input> inside the shadow root, and nothing ever named that input — so it is announced by its placeholder. Association is not naming.

Keyboard behavior is entirely yours. Native controls carry conventions the browser implements: arrow keys move between radio buttons, Escape closes a picker, Enter in a text field submits the form. A form-associated custom element inherits none of them — the inner input has no form owner, so Enter does not submit — and every one you want, you write and test yourself.

The honest summary

formAssociated and ElementInternals solve the part that was previously impossible: submission, validity, and the form's own lifecycle. They do not solve the part that was always merely hard. If you are wrapping a native control anyway, ask whether you need the shadow root at all — an <input> in the light DOM already has every behavior in this section, and step 04's decision framework exists for exactly this question.

Next Steps

In Step 9: Declarative shadow DOM, we'll explore:

  • <template shadowrootmode>: a shadow root that arrives from the parser, with no JavaScript at all
  • Hydration as adoption rather than reconstruction — upgrading against a root that already exists
  • Why innerHTML will not parse a declarative shadow root, and what setHTMLUnsafe() is warning you about
  • Serializing a shadow root back to HTML with getHTML()
  • What all of it means for a static site generator like the one serving this page