
What Nobody Tells You About ElementInternals: Why Your Web Components Are Failing in Native Forms
Stop using hidden input hacks to get your custom elements into a form; the ElementInternals API is the only way to achieve true native form participation.
If you’ve ever built a sleek custom checkbox only to realize your FormData object ignores its existence entirely, you’ve felt the specific, localized pain of Web Component form integration. It’s the "it looks like a duck, but doesn't quack like a duck" problem of the modern web, and most of us have been solving it with ugly hacks for far too long.
The "Hidden Input" Hack Must Die
For years, the standard advice for getting a Web Component to work inside a <form> was to shove a <input type="hidden"> inside your Shadow DOM. You’d manually sync the value of your component to that hidden input, and when the user hit submit, the form would grab the value from the hidden field.
It worked, but it was... gross. You had to manually handle state synchronization, you lost out on native validation (like :invalid CSS pseudo-classes), and accessibility was often an afterthought or a mountain of manual aria- attribute updates.
The ElementInternals API is the browser’s way of saying, "Okay, fine, let's do this properly."
Step 1: Telling the Form You Exist
The first thing nobody tells you is that your component needs to explicitly announce its intention to be "form-associated." Without this, the form will treat your component like a <div> or a <span>—essentially invisible to the submission process.
class MyStarRating extends HTMLElement {
// This is the magic switch
static formAssociated = true;
constructor() {
super();
this.attachShadow({ mode: 'open' });
// This is where the magic lives
this._internals = this.attachInternals();
this._value = 0;
}
}
customElements.define('my-star-rating', MyStarRating);By setting static formAssociated = true, you unlock the ability to use this.attachInternals(). This returns an object that acts as a bridge between your component’s private state and the outside form.
Giving the Form Your Data
In the old days, the form just looked for a name and a value. With ElementInternals, you explicitly tell the form what your value is using setFormValue().
set value(val) {
this._value = val;
// This tells the form: "Hey, my current value is X"
this._internals.setFormValue(val);
this.updateVisuals();
}
get value() {
return this._value;
}Now, if you put <my-star-rating name="quality" value="5"></my-star-rating> inside a form, new FormData(form).get('quality') will actually return "5". No hidden inputs required.
Native Validation: The Real Superpower
This is where things get interesting. Have you ever tried to make a Web Component "required"? If you just add the required attribute to your custom tag, the browser doesn't actually care. It doesn't know what "empty" looks like for your specific component.
With setValidity(), you can tap into the browser's native constraint validation engine.
checkValidity() {
if (this.hasAttribute('required') && !this.value) {
// We set a flag (valueMissing) and a custom message
this._internals.setValidity({ valueMissing: true }, "Please pick a star!");
} else {
// Clear the error
this._internals.setValidity({});
}
}When you call setValidity with an error flag, your component will now respond to the :invalid CSS pseudo-class on the *outside*. Your users can style your component just like a native <input> when it fails validation.
Pro-tip: You should call your validation logic whenever the value changes or when the component is first connected to the DOM.
Don't Forget Accessibility
The internals object isn't just for form values; it’s for AOM (Accessibility Object Model) support too. Instead of cluttering your HTML with role="slider" and aria-valuenow, you can set them internally. This keeps your DOM clean while giving screen readers the information they need.
connectedCallback() {
this._internals.role = 'slider';
this._internals.ariaValueNow = this.value;
this._internals.ariaValueMin = '0';
this._internals.ariaValueMax = '5';
}The "Gotchas" (Because there are always gotchas)
1. Safari Support: While ElementInternals is well-supported now, older versions of Safari (pre-16.4) were late to the party. If you need to support older browsers, you'll still need a polyfill (like ElementInternals-Polyfill).
2. Form Reset: Native inputs clear themselves when a form is reset. Your custom component won't—unless you implement the formResetCallback().
formResetCallback() {
this.value = this.getAttribute('value') || 0;
}Wrapping Up
Stop treating your Web Components like second-class citizens. Using hidden inputs is a legacy mindset that makes your components brittle and hard to maintain.
ElementInternals gives you a direct line to the form's logic, validation, and accessibility tree. It’s more code upfront, sure, but it’s code that follows the standards and survives the test of time. Go delete those hidden inputs. You'll feel better, I promise.


