Custom form controls get closer to native
Developers often build custom form controls—either to add capabilities the browser doesn't provide natively or to achieve a visual design that built-in widgets can't match. The hard part is replicating what a plain <input> gets for free: automatic inclusion in the form's control list, submission of its value, participation in validation with stylable :valid and :invalid states, and notifications for resets, reloads, and autofill attempts.
Some of those gaps can be closed with JavaScript workarounds, such as adding a hidden <input> to carry the value through submission. But other behaviors have been impossible to reproduce from script alone. Two complementary features remove those limitations: the formdata event and the form-associated custom elements API.
The formdata event
The formdata event is a low-level, event-based API that lets any JavaScript code inject data into a form submission. Works in Chrome 5+, Edge 12+, Firefox 4+, and Safari 5+.
To use it, add a formdata event listener directly to the form. When the user submits, the form fires that event with a FormData object holding all the submitted data. Each listener can then modify or add to that data before the actual submission happens.
The snippet below shows sending a single value from within a formdata listener:
const form = document.querySelector('form');
// FormData event is sent on <form> submission, before transmission.
// The event has a formData property
form.addEventListener('formdata', ({formData}) => {
// https://developer.mozilla.org/docs/Web/API/FormData
formData.append('my-input', myInputValue);
});
This approach works with any component, but it only touches the submission step. For deeper integration across the full form lifecycle, you need form-associated custom elements.
Form-associated custom elements
Form-associated custom elements aim to close the gap between custom widgets and built-in controls.
- A form-associated custom element inside a
<form>is automatically associated with that form, like any browser-provided control. - It can be labeled via a
<label>. - It can set a value that gets submitted with the form.
- It can report validity; invalid input blocks form submission.
- It gets lifecycle callbacks for events such as being disabled or reset.
- It responds to standard form-control CSS pseudo-classes like
:disabledand:invalid.
Defining and using one
Converting a custom element into a form-associated control takes three extra steps:
- Add a static
formAssociatedproperty to the class. - Call
attachInternals()to get theElementInternalsobject with control methods likesetFormValue()andsetValidity(). - Expose the standard control properties and methods, such as
name,value, andvalidity.
The basic class definition looks like this:
// Form-associated custom elements must be autonomous custom elements.
// They must extend HTMLElement, not one of its subclasses.
class MyCounter extends HTMLElement {
// Identify the element as a form-associated custom element
static formAssociated = true;
constructor() {
super();
// Get access to the internal form control APIs
this.internals_ = this.attachInternals();
// internal value for this control
this.value_ = 0;
}
// Form controls usually expose a "value" property
get value() { return this.value_; }
set value(v) { this.value_ = v; }
// The following properties and methods aren't strictly required,
// but browser-level form controls provide them. Providing them helps
// ensure consistency with browser-provided controls.
get form() { return this.internals_.form; }
get name() { return this.getAttribute('name'); }
get type() { return this.localName; }
get validity() {return this.internals_.validity; }
get validationMessage() {return this.internals_.validationMessage; }
get willValidate() {return this.internals_.willValidate; }
checkValidity() { return this.internals_.checkValidity(); }
reportValidity() {return this.internals_.reportValidity(); }
…
}
customElements.define('my-counter', MyCounter);
Once registered, the element works in markup just like any other form control:
<form>
<label>Number of bunnies: <my-counter></my-counter></label>
<button type="submit">Submit</button>
</form>
Setting a value
The setFormValue() method on the ElementInternals object sets the control's current value. It accepts three types:
A simple string assignment:
this.internals_.setFormValue(this.value_);
And an example with multiple values from a single control, such as a credit card input:
// Use the control's name as the base name for submitted data
const n = this.getAttribute('name');
const entries = new FormData();
entries.append(n + '-first-name', this.firstName_);
entries.append(n + '-last-name', this.lastName_);
this.internals_.setFormValue(entries);
Validation and styling
To join form validation, call setValidity() on the internals object:
// Assume this is called whenever the internal value is updated
onUpdateValue() {
if (!this.matches(':disabled') && this.hasAttribute('required') &&
this.value_ < 0) {
this.internals_.setValidity({customError: true}, 'Value cannot be negative.');
}
else {
this.internals_.setValidity({});
}
this.internals.setFormValue(this.value_);
}
You can then style the element with :valid and :invalid pseudo-classes exactly as you would a native control.
Lifecycle callbacks
Form-associated custom elements support four optional callbacks tied to the form lifecycle.
void formAssociatedCallback(form)
Fires when the browser associates the element with, or disassociates it from, a form.
void formDisabledCallback(disabled)
Fires when the element's disabled state changes—either from its own disabled attribute or because a disabled <fieldset> ancestor changed. Use it to disable shadow-DOM content in step.
void formResetCallback()
Fires after the form resets. The element should restore its default state—typically by reconciling properties like value or checked with the corresponding markup attributes.
void formStateRestoreCallback(state, mode)
Fires in two circumstances: on state restoration after navigation or browser restart (mode is "restore") and when input-assist features like autofill supply a value (mode is "autocomplete"). The first argument's type depends on how setFormValue() stored the state.
Handling state restoration
For most form-associated elements, the browser restores state using the value passed to setFormValue(). But the method accepts an optional second argument—a state parameter separate from the submittable value. Both parameters accept the same three types: string, File, or FormData.
this.internals_.setFormValue(value, state);
The key difference: value is what gets submitted to the server. The optional state is your internal snapshot of the control, which may include data that should never leave the client.
Consider a color picker with two modes, palette and RGB wheel. The submittable value is a canonical color like "#7fff00", but restoring the exact UI needs the mode as well:
this.internals_.setFormValue(this.value_,
this.mode_ + '/' + this.value_);
Your restoration logic then interprets that state:
formStateRestoreCallback(state, mode) {
if (mode == 'restore') {
// expects a state parameter in the form 'controlMode/value'
[controlMode, value] = state.split('/');
this.mode_ = controlMode;
this.value_ = value;
}
// Chrome doesn't handle autofill for form-associated custom elements.
// In the autofill case, you might need to handle a raw value.
}
For simpler controls, like a number input, the state parameter is redundant. If omitted, the value is forwarded to formStateRestoreCallback():
formStateRestoreCallback(state, mode) {
// Simple case, restore the saved value
this.value_ = state;
}
Feature detection and fallbacks
Detect support for both APIs before using them, as shown below. No polyfills exist for either feature. The conventional fallback remains your own hidden form element to carry the control's value, though advanced form-associated features—such as state restoration callbacks—will be hard to emulate without native support.
if ('FormDataEvent' in window) {
// formdata event is supported
}
if ('ElementInternals' in window &&
'setFormValue' in window.ElementInternals.prototype) {
// Form-associated custom elements are supported
}
The formdata event eliminates the hidden-<input> hack by exposing a hook into the submission pipeline. Form-associated custom elements extend that further to bring custom widgets into the fold of the full form lifecycle, delivering new levels of expressiveness in form UI while keeping native integration.



