Web component input missing from FormData: form-associated custom elements and ElementInternals
We replaced a phone number input with a design-system web component. The form looked identical, but the server received no phone field and required validation never fired. Inputs inside shadow DOM do not belong to the outer form. How formAssociated and ElementInternals fix submission, validation and reset.
Our design system team shipped <ds-phone-input>, a web component with a country picker and formatting.
The checkout team replaced a plain <input type="tel" name="phone"> with it. In the browser, the form
looked the same and typing worked. After deployment, the order service started rejecting orders:
422 Unprocessable Entity
{"errors": {"phone": ["The phone field is required."]}}
And the browser never showed the "Please fill out this field" message, although the component had a
required attribute.
What the form actually sent
In the console, on the checkout page:
const form = document.querySelector('form#checkout');
console.log([...new FormData(form).entries()]);
[["email", "a.customer@example.com"], ["address", "1 Example Street"], ["postcode", "1000"]]
No phone. The component's markup in the elements panel:
<ds-phone-input name="phone" required>
#shadow-root (open)
<select part="country">...</select>
<input part="number" type="tel" autocomplete="tel-national">
</ds-phone-input>
The real <input> lived inside the component's shadow root. It had no name attribute, and even if it
had, it would not count.
Confirming the boundary
const host = document.querySelector('ds-phone-input');
const inner = host.shadowRoot.querySelector('input');
console.log(inner.form); // null
console.log(form.elements.length); // 4: email, address, postcode, submit button
The inner input's form property was null: it was not associated with the outer form at all.
What we ruled out
- A missing
name. We addednameto the inner input in a test build; it still did not appear inFormData. - JavaScript submission code. The checkout used a native form submission handled by the browser, and
new FormData(form)reproduced the issue in isolation. - Framework hydration. The page was server-rendered HTML with no framework controlling the form.
Root cause
Form controls inside a shadow root are not associated with forms outside it. Shadow DOM is a boundary for
form association: the outer <form> only collects controls in its own tree (and those pointing to it with
the form attribute in the same tree). The custom element itself was not a form-associated element, so
neither the component nor its inner input contributed a value, validity or participation in reset.
Two ways a custom element can relate to forms
| Approach | How it works | Problems |
|---|---|---|
Light DOM: render a real <input> as a child of the custom element |
The input is in the outer tree, so the form sees it | Styles and structure are exposed, harder to encapsulate |
| Shadow DOM plus form-associated custom element | The element declares static formAssociated = true and reports its value through ElementInternals |
Needs explicit code for value, validity and reset |
Form-associated custom elements
A custom element class with static formAssociated = true behaves like a built-in form control:
- it can have a
nameand appears inform.elements; attachInternals()returns anElementInternalsobject;internals.setFormValue(value)sets what the form submits for this element;internals.setValidity(flags, message, anchor)participates in constraint validation, including:invalidstyling and the browser's validation bubble;- lifecycle callbacks such as
formResetCallbackandformDisabledCallbacklet the element react to the form.
Form-associated custom elements are supported in all current major browsers, including Safari since 16.4.
Who is affected
Any design system or component library that wraps native inputs in shadow DOM: text fields, selects,
date pickers, toggles and rich editors. Frameworks that compile to custom elements (Lit, Stencil, Angular
elements, Svelte custom elements) all follow the same platform rule. Pages that submit with JavaScript
by reading individual inputs are not affected, which is why the bug often goes unnoticed until a form is
switched to native submission or FormData.
Environment
| Component | Version / value |
|---|---|
| Component | <ds-phone-input>, Lit 3, open shadow root |
| Page | Server-rendered checkout, native form submission |
| Browsers | Current Chrome, Firefox, Safari, Edge |
| Server | Laravel form request validation, phone required |
Make the component form-associated
import { LitElement, html } from 'lit';
export class DsPhoneInput extends LitElement {
static formAssociated = true;
static properties = {
name: { type: String, reflect: true },
required: { type: Boolean, reflect: true },
value: { type: String },
};
#internals = this.attachInternals();
constructor() {
super();
this.value = '';
}
render() {
return html`
<select part="country" @change=${this.#onInput}>${/* country options */ ''}</select>
<input part="number" type="tel" autocomplete="tel-national"
.value=${this.value} @input=${this.#onInput}>`;
}
firstUpdated() {
this.#sync();
}
#onInput() {
const country = this.renderRoot.querySelector('select').value;
const number = this.renderRoot.querySelector('input').value.trim();
this.value = number ? `${country}${number}` : '';
this.#sync();
this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
}
#sync() {
const input = this.renderRoot.querySelector('input');
this.#internals.setFormValue(this.value);
if (this.required && !this.value) {
this.#internals.setValidity({ valueMissing: true }, 'Please enter a phone number.', input);
} else {
this.#internals.setValidity({});
}
}
formResetCallback() {
this.value = '';
this.renderRoot.querySelector('input').value = '';
this.#sync();
}
formStateRestoreCallback(state) {
this.value = state ?? '';
}
get form() { return this.#internals.form; }
get validity() { return this.#internals.validity; }
checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
}
customElements.define('ds-phone-input', DsPhoneInput);
Key points:
setFormValueis called on every change, and once at startup, so even an untouched field reports an empty value and its validity.setValiditywith an anchor element makes the browser show the validation bubble next to the inner input.- Exposing
form,validity,checkValidityandreportValiditymakes the element behave like a native control for page scripts too.
Labels and accessibility
A <label for="phone"> outside the component cannot point at an input inside the shadow root. We
forward the accessible name: the component reads the label text through internals.labels and sets
aria-label on the inner input. Newer platform features for cross-root ARIA references are still
evolving, so the explicit forwarding is the portable option.
Alternatives and trade-offs
- Render the input in the light DOM with a slot. Simplest form integration, but the checkout team could then restyle or break the internal structure.
- A hidden
<input>in the light DOM that mirrors the value. It works for submission, but validation and reset need extra code and the two values can drift apart.
Disabled state
A form-associated element is also disabled when its <fieldset> is disabled. The component implements
formDisabledCallback(disabled) to disable its inner controls, so a disabled fieldset around the shipping
address really disables the phone input too.
How to test the fix
A component test with a real browser (Web Test Runner with Playwright) covers submission, validation and reset:
it('participates in the form', async () => {
const el = await fixture(html`<form><ds-phone-input name="phone" required></ds-phone-input></form>`);
const form = el;
const input = form.querySelector('ds-phone-input');
expect(form.checkValidity()).to.be.false;
input.renderRoot.querySelector('input').value = '612345678';
input.renderRoot.querySelector('input').dispatchEvent(new Event('input'));
expect(new FormData(form).get('phone')).to.equal('+31612345678');
form.reset();
expect(new FormData(form).get('phone')).to.equal('');
});
Confirming in production
After the component release, 422 responses for missing phone numbers dropped to the background rate of
genuinely incomplete submissions. Empty required fields now showed the browser's validation message
before submission. Autofill continued to work, because the inner input keeps its autocomplete value.
Regression guard
- Every design-system component that represents a form value has a shared "form contract" test: appears
in
FormData, participates incheckValidity, resets with the form, respectsdisabled. - A Storybook story for each form component includes a real
<form>with a submit button.
Security implications
None for the bug itself. Validation in the browser is a usability feature; the server still validates the phone field, which is what caught the problem. Do not rely on client-side validity for anything that matters.
Prevention checklist
- Remember that shadow DOM inputs do not belong to outer forms.
- Use
static formAssociated = trueandElementInternalsfor form controls with shadow DOM. - Call
setFormValueandsetValidityon every change and at startup. - Implement reset, disabled and restore callbacks.
- Test components inside a real
<form>, not only in isolation.
References
- MDN, ElementInternals: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals
- MDN, ElementInternals.setFormValue(): https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/setFormValue
- HTML Standard, Form-associated custom elements: https://html.spec.whatwg.org/multipage/custom-elements.html#form-associated-custom-elements
- web.dev, More capable form controls: https://web.dev/articles/more-capable-form-controls
Add a label
Embed this post
<iframe src="https://www.hibug.me/custom-element-value-missing-form-submit-elementinternals" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.
Related Stories
CloudFront signed cookies set, every request still 403: cookie domain, path and key groups
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.