Form-Associated Custom Elements
What are "FACE" elements?
Form-Associated Custom Elements (FACE) allow custom elements to behave like native form controls (<input>, <select>, <textarea>). A form-associated element can:
- Submit values - Include data in
FormDatawhen the form is submitted - Validate input - Participate in constraint validation and block invalid submissions
- Report validity - Show browser validation UI with error messages
- External form association - Associate with forms outside the element's ancestry via the
formattribute - Receive labels - Support
<label for="...">for accessibility - React to form events - Handle reset, disabled, and state restoration
- Support CSS pseudo-classes - Style with
:valid/:invalidand custom states like:state(loading)
This is implemented via the ElementInternals API (HTML Spec).
The CustomElement factory supports creating form-associated custom elements that automatically participate in HTML forms. This enables React components wrapped as custom elements to seamlessly integrate with native form handling.
Basic Usage
Enable form association by setting formAssociated: true:
import { CustomElement } from "@webbloqs/proxies";
import { MyFormFields } from "./MyFormFields";
const MyCustomField = CustomElement({
reactComponent: MyFormFields,
formAssociated: true,
properties: {
name: String,
},
});
customElements.define("my-custom-field", MyCustomField);
<form id="my-form">
<my-custom-field name="contact"></my-custom-field>
<button type="submit">Submit</button>
</form>
Important: The
nameattribute is required for the element to participate in form association. Without aname, the element will not contribute data to the form, even ifformAssociated: trueis set. Thenameattribute can be added dynamically after the element is created.
The custom element will automatically:
- Associate with its parent form (or the form specified by the
formattribute) - Aggregate values from all named inputs inside its shadow DOM
- Include those values when the form is submitted or queried via
new FormData(form)
Examples
Simple Form Field
A React component with a single input that participates in forms:
// EmailField.tsx
export const EmailField = ({ label = "Email" }) => (
<div className="field">
<label>{label}</label>
<input type="email" name="email" />
</div>
);
// EmailFieldElement.ts
export const EmailFieldElement = CustomElement({
reactComponent: EmailField,
formAssociated: true,
properties: {
label: String,
},
});
customElements.define("email-field", EmailFieldElement);
<form>
<email-field name="user" label="Your Email"></email-field>
<button type="submit">Subscribe</button>
</form>
<!-- FormData will contain: { "user-email": "..." } -->
Composite Form Field
A React component with multiple inputs (e.g., an address field):
// AddressField.tsx
export const AddressField = () => (
<div className="address-field">
<input name="street" placeholder="Street" />
<input name="city" placeholder="City" />
<input name="zip" placeholder="ZIP" />
</div>
);
// AddressFieldElement.ts
export const AddressFieldElement = CustomElement({
reactComponent: AddressField,
formAssociated: true,
});
customElements.define("address-field", AddressFieldElement);
<form>
<address-field name="shipping"></address-field>
<address-field name="billing"></address-field>
<button type="submit">Place Order</button>
</form>
<!-- FormData will contain:
{
"shipping-street": "...",
"shipping-city": "...",
"shipping-zip": "...",
"billing-street": "...",
"billing-city": "...",
"billing-zip": "..."
}
-->
Custom Name Prefix
By default, child input names are prefixed with the element's name attribute followed by a hyphen (e.g., name="contact" → contact-email, contact-phone).
Override the default prefix with a custom value using the form-name-prefix attribute:
<form>
<payment-field name="card" form-name-prefix="payment_"></payment-field>
</form>
<!-- FormData will contain: { "payment_cardNumber": "...", "payment_expiry": "..." } -->
To disable prefixing entirely and use the child input names as-is, set form-name-prefix to an empty string:
<form>
<simple-field name="data" form-name-prefix=""></simple-field>
</form>
<!-- FormData will contain: { "cardNumber": "...", "expiry": "..." } (no prefix) -->
Note: Even when using an empty prefix, the
nameattribute is still required for the element to participate in form association. Thenameattribute identifies the element to the form;form-name-prefixonly controls how child input names are formatted in the form data.
Validation
Form-associated custom elements automatically aggregate validation state from child inputs and prevent form submission when invalid:
// RequiredEmailField.tsx
export const RequiredEmailField = () => (
<div>
<label>Email (required)</label>
<input type="email" name="email" required />
</div>
);
// RequiredEmailFieldElement.ts
export const RequiredEmailFieldElement = CustomElement({
reactComponent: RequiredEmailField,
formAssociated: true,
});
customElements.define("required-email-field", RequiredEmailFieldElement);
<form id="signup-form">
<required-email-field name="user"></required-email-field>
<button type="submit">Submit</button>
</form>
<script>
const field = document.querySelector('required-email-field');
// Check if field is valid
console.log(field.willValidate); // true
console.log(field.validity.valid); // false (if empty)
console.log(field.validationMessage); // "Please fill out this field."
// Validate programmatically
field.checkValidity(); // Returns false and fires 'invalid' event
field.reportValidity(); // Shows browser validation UI
</script>
The custom element exposes standard validation properties:
willValidate- Whether the element participates in validationvalidity- AValidityStateobject with flags likevalueMissing,typeMismatch, etc.validationMessage- The validation error messagecheckValidity()- Checks validity and firesinvalidevent if invalidreportValidity()- Shows validation UI to the user
Styling with CSS Pseudo-Classes
Form-associated custom elements support native CSS validation pseudo-classes automatically:
<form>
<email-field name="user" required></email-field>
</form>
<style>
/* Style invalid fields */
email-field:invalid {
border: 2px solid red;
}
/* Style valid fields */
email-field:valid {
border: 2px solid green;
}
</style>
These pseudo-classes work automatically when the element uses constraint validation via setValidity(). See MDN :valid and MDN :invalid.
For application-specific states (loading, dirty, etc.), you can use the custom states API:
const field = document.querySelector('my-field') as any;
field.states?.add('loading');
field.states?.delete('loading');
my-field:state(loading) {
opacity: 0.5;
pointer-events: none;
}
Lifecycle Callbacks
Form-associated custom elements receive lifecycle callbacks for form events:
formAssociatedCallback(form)
Called when the element is associated with or disassociated from a form:
// Automatically implemented by CustomElement
// You don't need to define this manually
formAssociatedCallback(form: HTMLFormElement | null) {
console.log('Associated with form:', form?.id);
}
formDisabledCallback(disabled)
Called when the element becomes disabled (via disabled attribute or ancestor fieldset[disabled]):
formDisabledCallback(disabled: boolean) {
console.log('Disabled state changed:', disabled);
// Child inputs are automatically disabled/enabled
}
formResetCallback()
Called when the associated form is reset:
formResetCallback() {
console.log('Form was reset');
// Form data and validation state are automatically cleared
}
formStateRestoreCallback(state, mode)
Called when the browser restores state (e.g., page refresh, autofill):
formStateRestoreCallback(state: FormData, mode: 'restore' | 'autocomplete') {
console.log('State restored:', mode);
// Child input values are automatically restored
}
External Form Association
Associate with a form that's not an ancestor using the form attribute:
<form id="checkout-form">
<button type="submit">Checkout</button>
</form>
<!-- Elsewhere in the DOM -->
<address-field name="delivery" form="checkout-form"></address-field>
Authoring Custom Inputs
This section provides a complete, copy-ready template for creating form-associated custom elements that wrap React components. The template incorporates lessons learned from production implementations.
Complete Template
The following template shows how to create a composite form field (like a phone number input with country code selection) that:
- Wraps a React component as a custom element
- Participates in HTML form submission via FACE
- Uses hidden field aggregation for canonical values
- Handles validation properly
// ═══════════════════════════════════════════════════════════════════════════
// FILE: PhoneNumberField.tsx - The React Component
// ═══════════════════════════════════════════════════════════════════════════
//
// This is a standard React component. It knows nothing about custom elements.
// Key principles:
// 1. Use SIMPLE field names (e.g., "country", "number") - no prefixes!
// FACE handles prefixing via the `formNamePrefix` attribute.
// 2. For composite inputs, use a hidden field to hold the canonical value
// that gets submitted. Display fields are for UX only.
// 3. The component receives props like any React component.
//
import React, { type FC, useState, useMemo, useRef, useEffect } from "react";
/**
* Props for the PhoneNumberField component.
*
* IMPORTANT: Include `name` and `form` in your props interface even though
* they're handled by FACE. This allows consumers to pass them via JSX.
* The `form` prop enables external form association (<element form="form-id">).
*/
export interface PhoneNumberFieldProps {
/** Element name for FACE form participation. Required for FormData. */
name?: string;
/** Form ID for external form association (optional). */
form?: string;
/** Label text displayed above the input. */
label?: string;
/** Whether a valid phone number is required. */
required?: boolean;
/** Whether the field is disabled. */
disabled?: boolean;
/** Initial phone number value. */
defaultValue?: string;
}
export const PhoneNumberField: FC<PhoneNumberFieldProps> = ({
// Note: `name` and `form` are not used directly in the React component.
// They're handled by the custom element wrapper (FACE).
// We include them in props so consumers can pass them via JSX.
label,
required = false,
disabled = false,
defaultValue = "",
}) => {
const [country, setCountry] = useState("CH");
const [inputNumber, setInputNumber] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
// Compute the canonical E.164 phone number from display values
const canonicalValue = useMemo(() => {
if (!inputNumber) return "";
const countryCode = country === "CH" ? "+41" : country === "DE" ? "+49" : "+33";
const cleaned = inputNumber.replace(/\D/g, "");
return `${countryCode}${cleaned}`;
}, [country, inputNumber]);
// Simple validation
const isValid = useMemo(() => {
if (!required && !inputNumber) return true;
return canonicalValue.length >= 10;
}, [required, inputNumber, canonicalValue]);
// Trigger validation when value changes
useEffect(() => {
if (inputRef.current && inputNumber) {
inputRef.current.setCustomValidity(isValid ? "" : "Invalid phone number");
}
}, [isValid, inputNumber]);
return (
<div className="phone-field">
{label && <label>{label}</label>}
<div className="phone-field__inputs">
{/* Country selector - display only, uses simple name */}
<select
name="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
disabled={disabled}
>
<option value="CH">🇨🇭 +41</option>
<option value="DE">🇩🇪 +49</option>
<option value="FR">🇫🇷 +33</option>
</select>
{/* Number input - display only, uses simple name */}
<input
ref={inputRef}
type="tel"
name="display-number"
value={inputNumber}
onChange={(e) => setInputNumber(e.target.value)}
placeholder="79 123 45 67"
disabled={disabled}
required={required}
/>
</div>
{/*
┌─────────────────────────────────────────────────────────────────────┐
│ HIDDEN FIELD AGGREGATION PATTERN │
│ │
│ This hidden input holds the CANONICAL value that gets submitted. │
│ The display inputs above are for UX only. │
│ │
│ Use this pattern when: │
│ - Multiple display fields contribute to one logical value │
│ - The submitted format differs from the display format │
│ - You need to normalize/transform user input before submission │
│ │
│ The `name` here should match what you want in FormData. │
│ FACE will prefix it: <element name="phone"> → "phone-number" │
└─────────────────────────────────────────────────────────────────────┘
*/}
<input type="hidden" name="number" value={canonicalValue} />
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════
// FILE: index.ts - The Custom Element Registration
// ═══════════════════════════════════════════════════════════════════════════
//
// This file creates the custom element class and registers it.
// Key principles:
// 1. Use `formAssociated: true` to enable FACE
// 2. Use `Omit<Props, "form">` to preserve FACE's native .form getter
// 3. Use `reflect: true` for `name` so the attribute syncs to the property
// 4. Don't add `form` as a property - it shadows the native FACE getter
//
import { CustomElement } from "@webbloqs/proxies";
import { PhoneNumberField, type PhoneNumberFieldProps } from "./PhoneNumberField";
/*
* ┌─────────────────────────────────────────────────────────────────────────┐
* │ CRITICAL: Omit<Props, "form"> │
* │ │
* │ FACE provides a native `.form` getter that returns the associated │
* │ HTMLFormElement. If you add `form: String` to properties, it creates │
* │ a property that SHADOWS this getter, breaking form association. │
* │ │
* │ The `form` ATTRIBUTE still works for external form association │
* │ (<element form="form-id">) - React sets it via JSX attribute handling. │
* └─────────────────────────────────────────────────────────────────────────┘
*/
export const PhoneNumberFieldElement = CustomElement<Omit<PhoneNumberFieldProps, "form">>({
reactComponent: PhoneNumberField,
formAssociated: true,
properties: {
/*
* ┌─────────────────────────────────────────────────────────────────────┐
* │ CRITICAL: name needs `reflect: true` │
* │ │
* │ Without reflection, the attribute doesn't sync to the property, │
* │ which can cause issues with form association and prefixing. │
* └─────────────────────────────────────────────────────────────────────┘
*/
name: { type: String, reflect: true },
// Optional: expose formNamePrefix if you want consumers to customize prefixing
// formNamePrefix: { type: String, reflect: true, attribute: "form-name-prefix" },
// Regular properties - no special handling needed
label: String,
required: Boolean,
disabled: Boolean,
defaultValue: String,
},
});
// Register the custom element
customElements.define("phone-number-field", PhoneNumberFieldElement);
export type { PhoneNumberFieldProps };
export default PhoneNumberFieldElement;
// ═══════════════════════════════════════════════════════════════════════════
// FILE: Usage Example
// ═══════════════════════════════════════════════════════════════════════════
import React from "react";
import "./index"; // Register the custom element
const ContactForm = () => {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
console.log(Object.fromEntries(formData));
// Output: { "contact-number": "+41791234567", "contact-country": "CH", ... }
};
return (
<form onSubmit={handleSubmit}>
{/* The custom element in JSX */}
<phone-number-field
name="contact"
label="Phone Number"
required
/>
<button type="submit">Submit</button>
</form>
);
};
Hidden Field Aggregation Pattern
When your component has multiple display inputs that contribute to a single logical value, use a hidden field to hold the canonical submitted value:
| Scenario | Display Fields | Hidden Field Value |
|---|---|---|
| Phone number | Country dropdown + number input | E.164 format: +41791234567 |
| Date picker | Day/month/year selects | ISO format: 2024-03-15 |
| Address autocomplete | Search input + suggestions | Structured JSON or ID |
| Currency input | Amount + currency selector | Normalized: 1234.56 USD |
When to use this pattern:
- The submitted format differs from the display format
- Multiple inputs contribute to one logical value
- You need validation/normalization before submission
When NOT to use:
- Each input represents independent data (use multi-field approach instead)
- The display format matches the submission format
Gotchas & Troubleshooting
These are non-obvious issues discovered during production implementations:
Don't Add form as a Property
Problem: Adding form: String to the properties object creates a property that shadows FACE's native .form getter, which returns the associated HTMLFormElement.
// ❌ WRONG - shadows the native .form getter
export const MyElement = CustomElement<MyProps>({
formAssociated: true,
properties: {
name: { type: String, reflect: true },
form: String, // This breaks .form getter!
},
});
// ✅ CORRECT - preserve native .form getter
export const MyElement = CustomElement<Omit<MyProps, "form">>({
formAssociated: true,
properties: {
name: { type: String, reflect: true },
// Don't include form here
},
});
The form attribute still works for external form association - React passes it as an attribute via JSX, and FACE handles it natively.
The name Property Needs reflect: true
Problem: Without reflect: true, the name attribute doesn't properly sync to the property, causing form association issues.
// ❌ WRONG - name won't reflect
properties: {
name: String,
}
// ✅ CORRECT - name reflects to attribute
properties: {
name: { type: String, reflect: true },
}
Don't Prefix Field Names in React
Problem: You might be tempted to prefix child field names like ${groupName}_${fieldName} inside your React component. This duplicates FACE's built-in prefixing.
// ❌ WRONG - manual prefixing in React
const AddressField = ({ name }) => (
<div>
<input name={`${name}_street`} /> {/* Results in "address-address_street" */}
<input name={`${name}_city`} />
</div>
);
// ✅ CORRECT - let FACE handle prefixing
const AddressField = () => (
<div>
<input name="street" /> {/* FACE prefixes to "address-street" */}
<input name="city" />
</div>
);
Solution: Use simple field names in React. FACE automatically prefixes them with {name}- (or your custom form-name-prefix).
Form Reset May Not Clear React State
Problem: When the form resets, FACE clears the form data, but React component state persists.
Solution: If your component uses controlled inputs with React state, handle the formResetCallback lifecycle or use uncontrolled inputs with defaultValue.
Validation Timing with Async Operations
Problem: If your component fetches data or validates asynchronously, the validation state may be stale when checked.
Solution: Use setTimeout(..., 0) to defer validation checks until after React re-renders:
useEffect(() => {
setTimeout(() => {
inputRef.current?.setCustomValidity(isValid ? "" : "Invalid");
}, 0);
}, [isValid]);
API Reference
CustomElement Options
| Option | Type | Default | Description |
|---|---|---|---|
formAssociated | boolean | false | Enable form association for the custom element |
Element Properties
Form-associated custom elements automatically expose these properties:
| Property | Type | Description |
|---|---|---|
name | string | Element name, used as default prefix for form field names |
form | HTMLFormElement | null | The associated form element |
formNamePrefix | string | Custom prefix for child input names |
willValidate | boolean | Whether the element participates in constraint validation |
validity | ValidityState | Validity state object with validation flags |
validationMessage | string | Validation error message |
labels | NodeList | null | Associated <label> elements |
states | CustomStateSet | Custom state set for app-specific CSS :state() pseudo-classes (e.g., :state(loading)) |
Element Methods
| Method | Returns | Description |
|---|---|---|
checkValidity() | boolean | Checks validity, fires invalid event if invalid |
reportValidity() | boolean | Checks validity and shows browser validation UI |
Element Attributes
| Attribute | Description |
|---|---|
name | Required for form participation. Element name, used as default prefix for form field names |
form | ID of a form to associate with (when not a descendant of the form) |
form-name-prefix | Custom prefix for child input names. Defaults to {name}- if not specified. Set to empty string for unprefixed names |
disabled | Disables the element. Per HTML spec, disabled elements are excluded from FormData and barred from constraint validation |
readonly | Makes the element read-only. Child inputs with readonly are included in FormData but cannot be edited. Per HTML spec, readonly inputs are barred from constraint validation |
Lifecycle Callbacks
Form-associated custom elements receive these lifecycle callbacks:
| Callback | Parameters | Description |
|---|---|---|
formAssociatedCallback | form: HTMLFormElement | null | Called when form owner changes |
formDisabledCallback | disabled: boolean | Called when disabled state changes |
formResetCallback | None | Called when associated form is reset |
formStateRestoreCallback | state: FormData, mode: 'restore' | 'autocomplete' | Called for autofill/restore |
Technical Details
Why Form-Associated Custom Elements?
Standard HTML form elements (<input>, <select>, etc.) automatically participate in form submission. However, custom elements with shadow DOM create an encapsulation boundary that prevents inner inputs from being included in the parent form's data.
Form-associated custom elements solve this by using the ElementInternals API to programmatically participate in forms.
How It Works
-
Form Association: The custom element declares
static formAssociated = trueand callsattachInternals()to get anElementInternalsobject. -
Form Value Reporting: When inputs inside the shadow DOM change, the element aggregates their values into a
FormDataobject and reports it viaElementInternals.setFormValue(). -
Constraint Validation: The element aggregates validation state from all child inputs using their
validityproperties and reports the combined state viaElementInternals.setValidity(). This prevents form submission when any child input is invalid. -
CSS Pseudo-Classes: The
setValidity()call automatically makes the element match native:validor:invalidCSS pseudo-classes. -
Form Participation: The browser includes the element's reported value when:
- Creating
FormDatafrom the form - Submitting the form
- Resetting the form
- Validating the form
- Creating
Specifications
This feature implements the Form-Associated Custom Elements specification from the WHATWG HTML Standard.
Key spec references:
- Custom Element Definition -
formAssociatedstatic property - ElementInternals Interface - Form participation APIs
- Form Owner - How elements associate with forms
Browser Support
Form-associated custom elements are supported in all modern browsers:
- Chrome 77+
- Firefox 93+
- Safari 16.4+
- Edge 79+
See Can I Use: Form-associated custom elements for current support status.
Future Enhancements
The following features are planned for future releases:
labelsproperty testing - Comprehensive E2E tests for label association vialabels- SSR behavior documentation - Document how form-associated custom elements behave during server-side rendering
- File/string state restoration - Support
Fileandstringtypes informStateRestoreCallbackfor complete spec compliance (currently only handles FormData) formStateRestoreCallbacktesting - E2E tests for browser state restoration (requires complex browser navigation/autofill simulation)- Nested fieldsets - Test and document behavior with nested
<fieldset disabled>elements - Dynamic form attribute changes - Test changing
form="a"toform="b"at runtime