You build a <star-rating> custom element. Five clickable stars, a nice hover state, a value property. You drop it into a signup form next to the real inputs. It looks identical to everything around it.
You submit the form. FormData comes back with the email, the password, the checkbox — and no rating. Not empty. Missing. As far as the <form> is concerned, your custom element was never there.
The form doesn't see what you think it sees
A <form> only collects values from elements it recognizes as form controls. That's a fixed list baked into the platform: <input>, <select>, <textarea>, <button>, a handful of others. A custom element — no matter how convincingly it renders, no matter what you name its property — isn't on that list. It can have a name attribute. It can have a value getter. The form still walks past it like it's a <div>, because as far as form submission is concerned, it is one.
Same story for the rest of the form contract you got for free with <input>: <label for="rating"> won't focus it, required won't block submission on it, :invalid won't style it, form.reset() won't clear it. None of that is wired up — there was never a mechanism for a custom element to opt in.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
The fix everyone reaches for first
The obvious move is a hidden mirror:
<star-rating id="stars"></star-rating>
<input type="hidden" name="rating" id="rating-mirror" />
stars.addEventListener("change", () => {
ratingMirror.value = stars.value;
});
This works, in the sense that a value now shows up in FormData. It also means you're maintaining a second element whose only job is being real where the first one is fake. Every place the rating can change — clicking a star, a keyboard shortcut, a preset button — needs to remember to update the mirror too, or the two drift apart silently.
required looks like the next box to tick, so someone adds it to the hidden input. It does nothing. <input type="hidden"> is explicitly excluded from constraint validation — the spec calls it "barred from constraint validation" — so a required hidden field is always considered valid, full stop. What actually ships instead is a hand-rolled check bolted onto the submit handler: if (!ratingMirror.value) event.preventDefault(). That's required, reimplemented in application code, with no :invalid styling and no native "please fill this out" message — because the one place that logic could have lived natively was never a candidate for it. form.reset() clears the hidden input's value but not the visible stars, so that's another listener to write by hand. Every native behavior you're missing gets re-implemented as its own event listener, on its own element, that has to stay in sync with the one the user actually sees.
The part of the platform built for exactly this
Custom elements have been able to register as real form participants since ElementInternals shipped — no hidden input, no manual sync:
class StarRating extends HTMLElement {
static formAssociated = true; // opts into the form-control contract
constructor() {
super();
this._internals = this.attachInternals();
this._value = 0;
}
set value(v) {
this._value = v;
this._internals.setFormValue(String(v)); // this is what FormData reads now
this._internals.setValidity(
this.required && v === 0 ? { valueMissing: true } : {},
"Pick a rating.",
this // anchor for the native validation bubble
);
}
get value() {
return this._value;
}
formResetCallback() {
this.value = 0; // form.reset() now clears the widget for free
}
formDisabledCallback(disabled) {
this.toggleAttribute("aria-disabled", disabled);
}
}
customElements.define("star-rating", StarRating);
attachInternals() hands back an ElementInternals object that's the element's private line to the form machinery — setFormValue() is what FormData actually reads, and it's the only line in this class that touches submission. setValidity() plugs into the same constraint-validation system required and :invalid already use, so form.checkValidity() and reportValidity() see this element exactly like a native input. formResetCallback() and formDisabledCallback() are lifecycle hooks the browser calls on its own — no listener to attach, no sync to forget.
The hidden-input version and this version produce the same FormData on a good day. They stop matching the moment something goes wrong: an empty required field, a form reset mid-interaction, a disabled fieldset. That's exactly the code the mirror-input hack never got right, because nothing forced it to.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
What this buys you
This isn't just less code — it's less code that lies about being finished. The hidden-input version looks like a form field to a skim-read, right up until QA resets the form or a screen reader user tabs to the <label> expecting it to do something. ElementInternals doesn't make custom elements harder to write; it makes them stop pretending to be inputs and actually become one, using the exact validation and submission pipeline the browser already runs for everything else on the page.
If you've got a design-system component masquerading as a form field with a hidden <input> glued to its side, that's a straightforward swap now. What's the ugliest hidden-input-sync hack you're still carrying?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)