Skip to main content

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:

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 name attribute is required for the element to participate in form association. Without a name, the element will not contribute data to the form, even if formAssociated: true is set. The name attribute 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 form attribute)
  • 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 name attribute is still required for the element to participate in form association. The name attribute identifies the element to the form; form-name-prefix only 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 validation
  • validity - A ValidityState object with flags like valueMissing, typeMismatch, etc.
  • validationMessage - The validation error message
  • checkValidity() - Checks validity and fires invalid event if invalid
  • reportValidity() - 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:

ScenarioDisplay FieldsHidden Field Value
Phone numberCountry dropdown + number inputE.164 format: +41791234567
Date pickerDay/month/year selectsISO format: 2024-03-15
Address autocompleteSearch input + suggestionsStructured JSON or ID
Currency inputAmount + currency selectorNormalized: 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

OptionTypeDefaultDescription
formAssociatedbooleanfalseEnable form association for the custom element

Element Properties

Form-associated custom elements automatically expose these properties:

PropertyTypeDescription
namestringElement name, used as default prefix for form field names
formHTMLFormElement | nullThe associated form element
formNamePrefixstringCustom prefix for child input names
willValidatebooleanWhether the element participates in constraint validation
validityValidityStateValidity state object with validation flags
validationMessagestringValidation error message
labelsNodeList | nullAssociated <label> elements
statesCustomStateSetCustom state set for app-specific CSS :state() pseudo-classes (e.g., :state(loading))

Element Methods

MethodReturnsDescription
checkValidity()booleanChecks validity, fires invalid event if invalid
reportValidity()booleanChecks validity and shows browser validation UI

Element Attributes

AttributeDescription
nameRequired for form participation. Element name, used as default prefix for form field names
formID of a form to associate with (when not a descendant of the form)
form-name-prefixCustom prefix for child input names. Defaults to {name}- if not specified. Set to empty string for unprefixed names
disabledDisables the element. Per HTML spec, disabled elements are excluded from FormData and barred from constraint validation
readonlyMakes 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:

CallbackParametersDescription
formAssociatedCallbackform: HTMLFormElement | nullCalled when form owner changes
formDisabledCallbackdisabled: booleanCalled when disabled state changes
formResetCallbackNoneCalled when associated form is reset
formStateRestoreCallbackstate: 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

  1. Form Association: The custom element declares static formAssociated = true and calls attachInternals() to get an ElementInternals object.

  2. Form Value Reporting: When inputs inside the shadow DOM change, the element aggregates their values into a FormData object and reports it via ElementInternals.setFormValue().

  3. Constraint Validation: The element aggregates validation state from all child inputs using their validity properties and reports the combined state via ElementInternals.setValidity(). This prevents form submission when any child input is invalid.

  4. CSS Pseudo-Classes: The setValidity() call automatically makes the element match native :valid or :invalid CSS pseudo-classes.

  5. Form Participation: The browser includes the element's reported value when:

    • Creating FormData from the form
    • Submitting the form
    • Resetting the form
    • Validating the form

Specifications

This feature implements the Form-Associated Custom Elements specification from the WHATWG HTML Standard.

Key spec references:

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:

  • labels property testing - Comprehensive E2E tests for label association via labels
  • SSR behavior documentation - Document how form-associated custom elements behave during server-side rendering
  • File/string state restoration - Support File and string types in formStateRestoreCallback for complete spec compliance (currently only handles FormData)
  • formStateRestoreCallback testing - 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" to form="b" at runtime