A custom element <money-input> wraps an <input> inside its shadow root. When the surrounding <form> is submitted, FormData contains no entry for it. Why is the value missing, and how do you make a custom element behave as a real form control?
answer
- form ownership does not cross the boundary
- promote the host, not the inner input
- a static flag read at definition time
- the browser hands you a private handle
- reset, disable and restore are callbacks too
basics
~20 sAn input inside a shadow root is not associated with a form in the outer document, so nothing it holds is submitted. Declare static formAssociated = true on the element, call this.attachInternals(), and report the value with internals.setFormValue().
solid answer
~40 sForm owner association does not cross the shadow boundary: the inner `<input>` is in a different tree from the `<form>`, so it never becomes one of the form's controls and contributes nothing to submission. The fix is not to move the input out but to make the host itself a form control. Set `static formAssociated = true` on the class, grab `const internals = this.attachInternals()` in the constructor, and call `internals.setFormValue(value)` whenever the value changes — the entry is submitted under the host element's `name` attribute. `ElementInternals` also gives you `internals.form`, `internals.labels`, and `internals.setValidity(flags, message, anchor)` for real constraint validation, plus the lifecycle callbacks `formResetCallback`, `formDisabledCallback` and `formStateRestoreCallback` so reset, fieldset disabling and back-navigation restore behave like a native control.
code
javascript · 27 linesclass MoneyInput extends HTMLElement {
static formAssociated = true;
#internals = this.attachInternals();
#input;
connectedCallback() {
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open', delegatesFocus: true });
if (!root.firstChild) root.innerHTML = '<input type="text" inputmode="decimal">';
this.#input = root.querySelector('input');
this.#input.addEventListener('input', () => this.#sync());
this.#sync();
}
#sync() {
const value = this.#input.value;
this.#internals.setFormValue(value);
this.#internals.setValidity(
value === '' ? { valueMissing: true } : {},
'Enter an amount',
this.#input,
);
}
formResetCallback() { this.#input.value = ''; this.#sync(); }
formDisabledCallback(disabled) { this.#input.disabled = disabled; }
}
customElements.define('money-input', MoneyInput);go deeper
Know that anything inside a shadow root is invisible to the surrounding form, so a wrapped input's value is simply not submitted.
Name the three required pieces — static formAssociated, attachInternals(), setFormValue() — and say that the submitted entry uses the host element's name attribute.
Cover the full control contract you are signing up for: validity with an anchor element, reset, fieldset disabling, state restoration on back-navigation, and focus delegation.
Decide whether a design system's inputs use shadow DOM plus ElementInternals or render into the light DOM, and justify the tradeoff between encapsulation and the amount of native control behaviour you must reimplement.
## Why the value disappears A form's list of controls is built from *form-associated elements whose form owner is that form*, and form ownership is resolved within one node tree. An `<input>` sitting inside a shadow root belongs to the shadow tree, not to the document tree that contains the `<form>`, so it never joins the form's controls list. It is not filtered out for lacking a `name` or for being hidden — it was never a candidate. `new FormData(form)` therefore has no entry, the form submits without it, and native validation never considers it. This is encapsulation working as designed: the shadow boundary hides the implementation from the outer document, and form association is part of what it hides. ## Form-associated custom elements The platform's answer is to promote the *host* to a form control. Three pieces: ```js class MoneyInput extends HTMLElement { static formAssociated = true; #internals = this.attachInternals(); set value(v) { this.#internals.setFormValue(v); } } customElements.define('money-input', MoneyInput); ``` - **`static formAssociated = true`** — read by the browser at definition time. It is what makes the element a form-associated custom element; without it `attachInternals()` still works but the element has no form behaviour and calling `setFormValue` throws. - **`this.attachInternals()`** — returns an `ElementInternals` object, the element's private handle on the state the browser normally manages for a built-in control. Call it once and keep it in a private field; a second call on the same element throws. - **`internals.setFormValue(value)`** — reports the current submission value. It accepts a string, a `File`, `FormData` (for a control that submits several entries), or `null` to submit nothing. The entry name comes from the *host's* `name` content attribute, so `<money-input name="amount">` submits as `amount`. Call `setFormValue` whenever the inner input changes, not only at submit time — the browser reads the last value you reported. ## Validity A real control also participates in constraint validation: ```js this.#internals.setValidity( { valueMissing: true }, 'Enter an amount', this.#inputEl, ); ``` The first argument is a partial `ValidityStateFlags` object, the second the message shown by the browser's bubble, and the third an *anchor* — the element the browser should scroll to and focus when reporting the problem, which may be inside your shadow root. Passing `{}` clears the invalid state. With validity set, the host takes part in `form.checkValidity()`, blocks submission like a native control, and matches `:invalid` / `:valid`. `ElementInternals` also exposes `internals.form` (the owning form, resolved for you), `internals.labels` (the `<label>` elements pointing at the host), `internals.willValidate`, `internals.validity` and `internals.checkValidity()`. ## The lifecycle callbacks Being a control means participating in things a plain element never sees. A form-associated custom element may define: - `formAssociatedCallback(form)` — the element's form owner changed, including to `null`. - `formResetCallback()` — the form was reset; restore your default value and clear validity. - `formDisabledCallback(disabled)` — the element or an ancestor `<fieldset>` was disabled; propagate to your inner control so it actually stops accepting input. - `formStateRestoreCallback(state, mode)` — the browser is restoring state after a back-navigation or a session restore, using whatever you passed as `setFormValue`'s optional second argument. This is how a native input keeps its typed value across the back button, and it is easy to forget. Skipping `formDisabledCallback` is the most common omission, and it produces a control the user can still type into inside a disabled fieldset. ## Accessibility and the rest A control also needs a role and a name. `ElementInternals` exposes ARIA defaults — `internals.role`, `internals.ariaLabel` and the other `aria*` properties — which set the element's *default* semantics without writing attributes onto the host, so a consumer's explicit `role` or `aria-label` still wins. Delegating focus matters too: `attachShadow({ mode: 'open', delegatesFocus: true })` makes clicking the host or a `<label>` focus the inner input, and makes the host focusable in a sane way. ## Availability Form-associated custom elements are supported across current browsers; Safari added `ElementInternals` form association in 16.4, which is why older guidance recommended the hidden-input workaround — mirroring the value into a real `<input type="hidden">` in the light DOM. That hack still submits, but it gets you none of the validation, reset, disabling or state-restoration behaviour, so treat it as a legacy fallback rather than the design.
- Under what name is the value submitted, and what happens if that name is missing?Under the `name` content attribute of the **host** element — `<money-input name="amount">` submits as `amount`. The inner input's own name is irrelevant since it is not in the form's tree. With no name on the host, the control participates in validation and reset but contributes no entry to the submission, exactly like a nameless native input.
- Why is mirroring the value into a hidden input in the light DOM an inferior solution?It gets the value submitted and nothing else. The host never joins the form's controls list, so `form.checkValidity()` ignores it, `:invalid` never matches, reset does not restore it, a disabled `<fieldset>` does not disable it, and back-navigation does not restore what the user typed. You would reimplement each of those by hand.
- Which callback keeps the control honest inside a disabled fieldset?`formDisabledCallback(disabled)`. The browser calls it when the host or an ancestor `<fieldset>` becomes disabled, and it is your job to propagate that to the inner input and update styling. Omit it and the shadow input stays fully editable inside a fieldset the user was told is disabled.
saying these in an interview costs you the question
- Expecting a shadow-DOM input to submit with the outer form
- Calling attachInternals more than once per element
- Setting formAssociated as an instance property instead of static
- Reporting the value only at submit time
- Ignoring reset, disable and state-restore callbacks