Form Participation Fundamentals
apps/docs/src/content/docs/components/forms/fundamentals Click to copy apps/docs/src/content/docs/components/forms/fundamentals Form Participation Fundamentals
Section titled “Form Participation Fundamentals”Form-associated custom elements are first-class citizens in the web platform. With the ElementInternals API, your web components can participate in native HTML forms just like built-in <input>, <select>, and <textarea> elements. This means automatic form submission, constraint validation, accessibility integration, and seamless interoperability with frameworks and server-side rendering.
This guide covers everything you need to build production-grade form components: the ElementInternals API, form association lifecycle, value management, validation patterns, and integration with native form features. By the end, you’ll understand how to build form controls that feel native to the platform.
Why Form Association Matters
Section titled “Why Form Association Matters”Built-in form controls (<input>, <select>, etc.) have deep integration with the browser:
- Automatic form submission — Values are serialized and submitted with the form
- Validation API — Constraint validation with native browser UI
- Accessibility — Screen readers announce validation states, required fields, etc.
- Form reset/restore — Browser handles reset and state restoration (back/forward navigation)
- Label association — Click a
<label>to focus the control - FormData integration — Values appear in
FormDataandURLSearchParams
Without form association, custom elements are invisible to these APIs. A custom <my-input> won’t submit with the form, won’t validate, and won’t work with assistive technologies.
The ElementInternals API solves this by giving custom elements the same capabilities as built-in controls.
Form Association Overview
Section titled “Form Association Overview”At a high level, making a custom element form-associated involves:
- Declare
static formAssociated = trueon your class - Attach
ElementInternalsin the constructor viathis.attachInternals() - Set form value via
this._internals.setFormValue() - Implement validation via
this._internals.setValidity() - Implement lifecycle callbacks (
formResetCallback,formStateRestoreCallback)
Here’s a minimal example:
import { LitElement, html } from 'lit';import { customElement, property } from 'lit/decorators.js';
@customElement('my-simple-input')export class MySimpleInput extends LitElement { // Step 1: Declare form association static formAssociated = true;
private _internals: ElementInternals;
constructor() { super(); // Step 2: Attach ElementInternals this._internals = this.attachInternals(); }
@property({ type: String }) value = '';
@property({ type: String }) name = '';
updated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if (changedProperties.has('value')) { // Step 3: Set form value this._internals.setFormValue(this.value); } }
// Step 5: Implement reset callback formResetCallback() { this.value = ''; this._internals.setFormValue(''); }
render() { return html` <input type="text" .value=${this.value} @input=${(e: Event) => { this.value = (e.target as HTMLInputElement).value; }} /> `; }}Now this component works in a form:
<form> <my-simple-input name="username"></my-simple-input> <button type="submit">Submit</button></form>When the form submits, the browser includes username=<value> in the submission.
The ElementInternals API
Section titled “The ElementInternals API”ElementInternals is the bridge between your custom element and the browser’s form APIs. It provides methods for:
- Form value management —
setFormValue() - Validation —
setValidity(),checkValidity(),reportValidity() - Form access —
formproperty - Accessibility — ARIA role and state management (covered in accessibility docs)
Creating ElementInternals
Section titled “Creating ElementInternals”ElementInternals is created once in the constructor via attachInternals():
export class HelixInput extends LitElement { static formAssociated = true;
private _internals: ElementInternals;
constructor() { super(); this._internals = this.attachInternals(); }}Key constraints:
- Call exactly once per element. Calling
attachInternals()twice throws — the constructor is the safest place to call it, but later lifecycle calls only throw if a previous call already succeeded. formAssociated = trueenables form participation.attachInternals()itself is callable on any custom element (it’s how non-form-associated components reach ARIA / states / shadowRoot helpers); it only enables form callbacks likeformAssociatedCallback/formResetCallbackwhenstatic formAssociated = trueis declared.- HELiX shortcut. Inside
@helixui/library, components extendHelixElementand accessthis._internals— a lazy accessor that callsattachInternals()on first read. Form-participating subclasses additionally setstatic override formAssociated = true. You don’t write the constructor / private field yourself.
TypeScript Typing
Section titled “TypeScript Typing”The ElementInternals interface is built into TypeScript’s DOM types (lib.dom.d.ts). No additional imports needed:
private _internals: ElementInternals;For stricter typing, you can validate that form-associated methods exist:
constructor() { super(); this._internals = this.attachInternals();
// TypeScript knows these properties exist this._internals.setFormValue(''); this._internals.setValidity({}); console.log(this._internals.form); // HTMLFormElement | null}The formAssociated Static Property
Section titled “The formAssociated Static Property”The static formAssociated = true declaration tells the browser that your custom element should participate in forms. This is a static property on the class, not an instance property.
export class HelixInput extends LitElement { // Static property: set once on the class static formAssociated = true;
constructor() { super(); this._internals = this.attachInternals(); // Now allowed }}Without this flag:
export class BrokenInput extends LitElement { // Missing: static formAssociated = true
constructor() { super(); this._internals = this.attachInternals(); // Throws DOMException! }}Error: DOMException: Failed to execute 'attachInternals' on 'HTMLElement': Unable to attach ElementInternals to a non-form-associated custom element.
Checking Form Association at Runtime
Section titled “Checking Form Association at Runtime”You can check if an element is form-associated via the constructor:
const input = document.createElement('hx-text-input');console.log(input.constructor.formAssociated); // true
const div = document.createElement('div');console.log(div.constructor.formAssociated); // undefinedThis is rarely needed, but useful for debugging or generic form utilities.
Accessing the Associated Form
Section titled “Accessing the Associated Form”The form property (read-only) returns the <form> element that contains the custom element, or null if it’s not inside a form.
export class HelixInput extends LitElement { static formAssociated = true; private _internals: ElementInternals;
constructor() { super(); this._internals = this.attachInternals(); }
get form(): HTMLFormElement | null { return this._internals.form; }}Usage Examples
Section titled “Usage Examples”Accessing the form
Section titled “Accessing the form”connectedCallback() { super.connectedCallback();
if (this._internals.form) { console.log('Part of form:', this._internals.form.id); } else { console.log('Standalone (not in a form)'); }}Adding form event listeners
Section titled “Adding form event listeners”connectedCallback() { super.connectedCallback();
if (this._internals.form) { this._internals.form.addEventListener('submit', this._handleFormSubmit); }}
disconnectedCallback() { super.disconnectedCallback();
if (this._internals.form) { this._internals.form.removeEventListener('submit', this._handleFormSubmit); }}
private _handleFormSubmit = (e: Event) => { console.log('Form is submitting');};Validating all controls in the form
Section titled “Validating all controls in the form”validateAllControls() { if (!this._internals.form) return true;
const controls = this._internals.form.elements; let allValid = true;
for (const control of Array.from(controls)) { if ('checkValidity' in control && typeof control.checkValidity === 'function') { if (!control.checkValidity()) { allValid = false; } } }
return allValid;}Form Attribute (form=”…”)
Section titled “Form Attribute (form=”…”)”Like built-in inputs, form-associated custom elements support the form attribute to associate with a form outside their DOM tree:
<form id="my-form"> <button type="submit">Submit</button></form>
<!-- This input is associated with the form via the form attribute --><hx-text-input name="username" form="my-form"></hx-text-input>The browser handles this automatically. Your component’s this._internals.form will point to the <form id="my-form"> element.
Note: This works without any additional code in your component. The platform handles it.
Setting the Form Value
Section titled “Setting the Form Value”The setFormValue() method tells the browser what value to submit when the form is submitted. This is the most important method in the ElementInternals API.
Signature
Section titled “Signature”setFormValue(value: File | string | FormData | null, state?: File | string | FormData | null): void;value— The value to submit (string, File, FormData, or null)state(optional) — Internal state for restoration (defaults tovalue)
Simple Value (String)
Section titled “Simple Value (String)”Most form controls submit a single string value:
export class HelixInput extends LitElement { @property({ type: String }) value = '';
updated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if (changedProperties.has('value')) { this._internals.setFormValue(this.value); } }}Form submission:
<form> <hx-text-input name="username" value="alice"></hx-text-input> <!-- Submits: username=alice --></form>Null Value (Unchecked Controls)
Section titled “Null Value (Unchecked Controls)”For checkboxes, radio buttons, or optional fields, use null when the control has no value:
export class HelixCheckbox extends LitElement { @property({ type: Boolean }) checked = false;
@property({ type: String }) value = 'on';
updated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if (changedProperties.has('checked') || changedProperties.has('value')) { // Only submit value if checked this._internals.setFormValue(this.checked ? this.value : null); } }}Form submission:
<form> <hx-checkbox name="agree" checked></hx-checkbox> <!-- Submits: agree=on -->
<hx-checkbox name="subscribe"></hx-checkbox> <!-- Submits: (nothing) --></form>File Value
Section titled “File Value”For file inputs, pass a single File, a FormData (for multiple files), or null. setFormValue() does not accept a raw FileList — extract the file(s) you want to submit and convert as needed:
export class HelixFileInput extends LitElement { private _file: File | null = null;
private _handleFileChange(e: Event) { const input = e.target as HTMLInputElement; this._file = input.files?.[0] || null; this._internals.setFormValue(this._file); }
render() { return html` <input type="file" @change=${this._handleFileChange} /> `; }}FormData Value (Multiple Fields)
Section titled “FormData Value (Multiple Fields)”For complex controls that submit multiple fields (e.g., a date picker that submits year, month, day):
export class HelixDatePicker extends LitElement { @property({ type: String }) name = '';
private _year = 2026; private _month = 2; private _day = 16;
private _updateFormValue() { const formData = new FormData(); formData.append(`${this.name}-year`, String(this._year)); formData.append(`${this.name}-month`, String(this._month)); formData.append(`${this.name}-day`, String(this._day));
this._internals.setFormValue(formData); }
// Call _updateFormValue() whenever year/month/day changes}Form submission:
<form> <hx-date-picker name="birthdate" value="2026-02-16"></hx-date-picker> <!-- hx-date-picker submits a single ISO 8601 date string: birthdate=2026-02-16 Use a FormData entry-list (multiple setFormValue calls) only when a control genuinely needs split year/month/day submission — that's the advanced FormData state pattern, not the hx-date-picker default. --></form>State Parameter (Advanced)
Section titled “State Parameter (Advanced)”The second parameter to setFormValue() is the state, used for form restoration (browser back/forward, session restore):
setFormValue(value: string, state?: string): void;value— What the form submitsstate— What the browser saves for restoration (defaults tovalue)
When to Use State
Section titled “When to Use State”Use state when your control has internal state that differs from the submitted value. For example:
- A rich text editor that submits HTML but internally tracks a JSON structure
- A multi-select that submits comma-separated values but internally tracks an array
- A date picker that submits ISO 8601 but internally tracks separate year/month/day
Example: Multi-Select
export class HelixMultiSelect extends LitElement { @property({ type: Array }) selectedValues: string[] = [];
private _updateFormValue() { // Submit: Comma-separated string const submissionValue = this.selectedValues.join(',');
// State: JSON array (for restoration) const state = JSON.stringify(this.selectedValues);
this._internals.setFormValue(submissionValue, state); }
formStateRestoreCallback(state: string) { // Restore from JSON this.selectedValues = JSON.parse(state); }}For most components, you don’t need the state parameter. The default (state = value) is sufficient.
Form State Management
Section titled “Form State Management”Form-associated custom elements participate in the browser’s form state lifecycle. This includes:
- Form reset — User clicks
<button type="reset">or callsform.reset() - Form state restoration — Browser back/forward navigation or session restore
formResetCallback()
Section titled “formResetCallback()”Called when the form is reset. Your component should restore its default state.
formResetCallback(): void { this.value = ''; this._internals.setFormValue('');}Example: Text Input
Section titled “Example: Text Input”export class HelixTextInput extends LitElement { @property({ type: String }) value = '';
formResetCallback() { this.value = ''; // Reset to default this._internals.setFormValue(''); }}Example: Checkbox
Section titled “Example: Checkbox”export class HelixCheckbox extends LitElement { @property({ type: Boolean }) checked = false;
@property({ type: Boolean }) indeterminate = false;
formResetCallback() { this.checked = false; this.indeterminate = false; this._internals.setFormValue(null); }}Example: Select with Default
Section titled “Example: Select with Default”export class HelixSelect extends LitElement { @property({ type: String }) value = '';
private _defaultValue = ''; // Track initial value
connectedCallback() { super.connectedCallback(); // Capture default value when first connected this._defaultValue = this.value; }
formResetCallback() { // Reset to initial value, not empty string this.value = this._defaultValue; this._internals.setFormValue(this._defaultValue); }}Key constraints:
- Always update both the property and form value —
this.value = ''; this._internals.setFormValue(''); - Reset to the default, not always empty — Check the HTML spec for your control type
- No return value — This is a void method
formStateRestoreCallback()
Section titled “formStateRestoreCallback()”Called when the browser restores form state (browser back/forward, session restore). The browser passes the state that was saved via setFormValue(value, state).
formStateRestoreCallback( state: File | string | FormData | null, mode: 'restore' | 'autocomplete'): void;state— The state value passed tosetFormValue()(or the value if no state was provided). May benullwhen the browser restores without a previously saved value.mode— Either'restore'(back/forward) or'autocomplete'(autocomplete feature)
Example: Text Input
Section titled “Example: Text Input”export class MyTextInput extends LitElement { @property({ type: String }) value = '';
formStateRestoreCallback(state: File | string | FormData | null) { // Only treat state as the input's string value when it actually is one. if (typeof state === 'string') { this.value = state; } }}Example: Checkbox
Section titled “Example: Checkbox”export class HelixCheckbox extends LitElement { @property({ type: Boolean }) checked = false;
@property({ type: String }) value = 'on';
formStateRestoreCallback(state: string) { // State is the value string if checked, or null if unchecked this.checked = state === this.value; }}Example: Multi-Select (JSON State)
Section titled “Example: Multi-Select (JSON State)”export class HelixMultiSelect extends LitElement { @property({ type: Array }) selectedValues: string[] = [];
formStateRestoreCallback(state: string) { // Restore from JSON state try { this.selectedValues = JSON.parse(state); } catch { this.selectedValues = []; } }}Key constraints:
- Always restore the component state — Update properties to match the state
- Handle invalid state gracefully — Wrap in try/catch if parsing
- Don’t call
setFormValue()— The browser already has the value; you’re just syncing
formDisabledCallback() — required for <fieldset disabled> propagation
Section titled “formDisabledCallback() — required for <fieldset disabled> propagation”Called when the element’s disabled state changes due to a containing <fieldset> being disabled. The browser does not propagate fieldset-disabled state to custom elements automatically — you have to handle this callback for :disabled semantics to behave like a native input.
// Raw-platform shapeformDisabledCallback(disabled: boolean): void { this.disabled = disabled;}Inside HELiX, HelixElement exposes a _onFormDisabled(disabled: boolean) hook that FormMixin wires into the platform callback — every shipped form component overrides _onFormDisabled (not formDisabledCallback directly) so the inherited base owns the platform-callback shape and subclasses only sync their internal state:
// HELiX subclass patternprotected override _onFormDisabled(disabled: boolean): void { this.disabled = disabled;}Form Validation
Section titled “Form Validation”The ElementInternals API provides full integration with the browser’s constraint validation API. Your custom element can participate in form validation just like <input required> or <input type="email">.
Validation Methods
Section titled “Validation Methods”ElementInternals exposes three validation methods:
// Set the validity statesetValidity( flags: ValidityStateFlags, message?: string, anchor?: HTMLElement): void;
// Check if valid (without showing UI)checkValidity(): boolean;
// Check if valid (with browser validation UI)reportValidity(): boolean;ValidityStateFlags
Section titled “ValidityStateFlags”The ValidityStateFlags object defines which validation constraints are violated:
interface ValidityStateFlags { valueMissing?: boolean; // Required field is empty typeMismatch?: boolean; // Value doesn't match type (e.g., invalid email) patternMismatch?: boolean; // Value doesn't match pattern attribute tooLong?: boolean; // Value exceeds maxlength tooShort?: boolean; // Value is shorter than minlength rangeUnderflow?: boolean; // Value < min rangeOverflow?: boolean; // Value > max stepMismatch?: boolean; // Value doesn't match step badInput?: boolean; // Browser can't parse input customError?: boolean; // Custom validation failed}Setting Validity
Section titled “Setting Validity”Use setValidity() to mark the element as valid or invalid:
this._internals.setValidity({}); // Empty object = validInvalid: Required Field
Section titled “Invalid: Required Field”if (this.required && !this.value) { this._internals.setValidity( { valueMissing: true }, 'This field is required.', this._inputElement, );}Invalid: Custom Validation
Section titled “Invalid: Custom Validation”if (this.value.length > 0 && this.value.length < 3) { this._internals.setValidity( { customError: true }, 'Must be at least 3 characters.', this._inputElement, );}Invalid: Multiple Constraints
Section titled “Invalid: Multiple Constraints”if (this.required && !this.value) { this._internals.setValidity({ valueMissing: true }, 'This field is required.');} else if (this.value && !this._isValidEmail(this.value)) { this._internals.setValidity({ typeMismatch: true }, 'Please enter a valid email address.');} else { this._internals.setValidity({});}Validation Parameters
Section titled “Validation Parameters”setValidity(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement): void;flags— Which validation constraints are violatedmessage— Error message forvalidationMessagepropertyanchor— Element to anchor browser validation UI to (e.g., the native input)
The anchor parameter is critical for browser validation UI (the tooltip that appears when you call reportValidity()):
// Good: Anchor to the native inputthis._internals.setValidity( { valueMissing: true }, 'This field is required.', this._input, // Points to the <input> in the shadow DOM);
// Less ideal: No anchor (UI appears on the custom element itself)this._internals.setValidity({ valueMissing: true }, 'This field is required.');Exposing Validation Properties
Section titled “Exposing Validation Properties”Form controls expose these standard properties (mirroring <input>):
export class HelixInput extends LitElement { /** Returns the associated form element, if any. */ get form(): HTMLFormElement | null { return this._internals.form; }
/** Returns the validation message. */ get validationMessage(): string { return this._internals.validationMessage; }
/** Returns the ValidityState object. */ get validity(): ValidityState { return this._internals.validity; }
/** Checks whether the input satisfies its constraints. */ checkValidity(): boolean { return this._internals.checkValidity(); }
/** Reports validity and shows the browser's constraint validation UI. */ reportValidity(): boolean { return this._internals.reportValidity(); }}These getters delegate to ElementInternals, giving your component the same API as <input>.
Validation Lifecycle
Section titled “Validation Lifecycle”Validation should run at these key points:
- On value change — Update validity when the value changes
- On property change — Update validity when
required,minlength, etc. change - On blur — Optionally show validation errors only after the user leaves the field
Example: Text Input
Section titled “Example: Text Input”export class HelixTextInput extends LitElement { @property({ type: String }) value = '';
@property({ type: Boolean }) required = false;
@property({ type: Number }) minlength?: number;
@query('.field__input') private _input!: HTMLInputElement;
updated(changedProperties: Map<string, unknown>) { super.updated(changedProperties);
// Validate when value or validation properties change if ( changedProperties.has('value') || changedProperties.has('required') || changedProperties.has('minlength') ) { this._updateValidity(); } }
private _updateValidity(): void { // Required validation if (this.required && !this.value) { this._internals.setValidity({ valueMissing: true }, 'This field is required.', this._input); return; }
// Minlength validation if (this.minlength && this.value.length < this.minlength) { this._internals.setValidity( { tooShort: true }, `Please enter at least ${this.minlength} characters.`, this._input, ); return; }
// Valid this._internals.setValidity({}); }}Form Submission Validation
Section titled “Form Submission Validation”When a form is submitted, the browser automatically calls checkValidity() on all form controls. If any return false, submission is blocked.
<form> <hx-text-input name="username" required></hx-text-input> <button type="submit">Submit</button></form>
<script> const form = document.querySelector('form'); form.addEventListener('submit', (e) => { e.preventDefault(); console.log('Form submitted!'); // Only logs if all controls are valid });</script>If the user tries to submit an empty required field, the browser:
- Calls
checkValidity()on the<hx-text-input> checkValidity()returnsfalse- Browser blocks submission and focuses the invalid field
- Browser shows validation UI (if
reportValidity()was called)
Manual Validation Trigger
Section titled “Manual Validation Trigger”You can manually trigger validation UI:
const input = document.querySelector('hx-text-input');
// Check validity (no UI)if (!input.checkValidity()) { console.log('Invalid:', input.validationMessage);}
// Check validity and show browser UIif (!input.reportValidity()) { console.log('Invalid and UI shown');}Custom Validation Logic
Section titled “Custom Validation Logic”For complex validation (e.g., async server-side checks), use the customError flag:
export class HelixUsernameInput extends LitElement { @property({ type: String }) value = '';
private _isValidating = false;
async validateUsername() { this._isValidating = true;
try { const response = await fetch(`/api/check-username?username=${this.value}`); const { available } = await response.json();
if (!available) { this._internals.setValidity( { customError: true }, 'This username is already taken.', this._input, ); } else { this._internals.setValidity({}); } } finally { this._isValidating = false; } }
private _handleBlur() { if (this.value) { this.validateUsername(); } }}Form Data Serialization
Section titled “Form Data Serialization”When a form is submitted, the browser serializes all form-associated elements into a FormData object or URL-encoded string.
FormData API
Section titled “FormData API”<form id="my-form"> <hx-text-input name="username" value="alice"></hx-text-input> <hx-checkbox name="subscribe" checked></hx-checkbox> <button type="submit">Submit</button></form>
<script> const form = document.querySelector('#my-form'); const formData = new FormData(form);
console.log(formData.get('username')); // "alice" console.log(formData.get('subscribe')); // "on"
// Iterate all fields for (const [name, value] of formData) { console.log(name, value); }</script>URLSearchParams
Section titled “URLSearchParams”const form = document.querySelector('#my-form');const formData = new FormData(form);const params = new URLSearchParams(formData);
console.log(params.toString()); // "username=alice&subscribe=on"Fetch API
Section titled “Fetch API”const form = document.querySelector('#my-form');const formData = new FormData(form);
fetch('/api/submit', { method: 'POST', body: formData, // Browser serializes automatically});Name Attribute
Section titled “Name Attribute”The name attribute is required for form submission. Without it, the control’s value is not submitted:
<!-- This value IS submitted --><hx-text-input name="username" value="alice"></hx-text-input>
<!-- This value is NOT submitted (no name) --><hx-text-input value="bob"></hx-text-input>In your component:
@property({ type: String })name = '';The browser automatically reads the name attribute. You don’t need to do anything special.
Integration with Native Forms
Section titled “Integration with Native Forms”Form-associated custom elements work seamlessly with all native form features.
Form Submission
Section titled “Form Submission”<form action="/submit" method="POST"> <hx-text-input name="username" required></hx-text-input> <hx-text-input name="email" type="email" required></hx-text-input> <button type="submit">Submit</button></form>- Clicking “Submit” triggers validation
- If valid, form submits to
/submitwithusernameandemail - If invalid, submission is blocked and browser shows validation UI
Form Reset
Section titled “Form Reset”<form> <hx-text-input name="username" value="alice"></hx-text-input> <button type="reset">Reset</button></form>- Clicking “Reset” calls
formResetCallback()on all form controls - Each control restores its default value
Form Validation Events
Section titled “Form Validation Events”Forms dispatch validation events:
const form = document.querySelector('form');
form.addEventListener('submit', (e) => { e.preventDefault(); console.log('Form submitted');});
form.addEventListener( 'invalid', (e) => { console.log('Invalid control:', e.target); }, true,); // Use capture to catch events from custom elementsLabel Association
Section titled “Label Association”Labels work with form-associated custom elements:
<label for="username">Username:</label><hx-text-input id="username" name="username"></hx-text-input>Clicking the <label> focuses the <hx-text-input>. This works automatically if your component:
- Implements
focus()method - Delegates focus to the internal input
export class HelixTextInput extends LitElement { @query('.field__input') private _input!: HTMLInputElement;
/** Moves focus to the input element. */ override focus(options?: FocusOptions): void { this._input?.focus(options); }}Disabled State
Section titled “Disabled State”The disabled attribute is honored by the browser:
<hx-text-input name="username" disabled></hx-text-input>- Disabled controls don’t submit with the form
- The
:disabledCSS pseudo-class matches - Assistive technologies announce the disabled state
In your component:
@property({ type: Boolean, reflect: true })disabled = false;The reflect: true option ensures the attribute updates when the property changes, keeping CSS selectors like :disabled working.
Real-World Examples
Section titled “Real-World Examples”Example 1: hx-text-input
Section titled “Example 1: hx-text-input”Simplified illustration (the actual packages/hx-library/src/components/hx-text-input/hx-text-input.ts extends HelixElement + applies FormMixin and uses the inherited lazy _internals accessor — see the file for the full source):
import { LitElement, html } from 'lit';import { customElement, property, query } from 'lit/decorators.js';import { live } from 'lit/directives/live.js';
@customElement('hx-text-input')export class HelixTextInput extends LitElement { static formAssociated = true;
private _internals: ElementInternals;
constructor() { super(); this._internals = this.attachInternals(); }
@property({ type: String }) value = '';
@property({ type: String }) name = '';
@property({ type: Boolean, reflect: true }) required = false;
@property({ type: Boolean, reflect: true }) disabled = false;
@property({ type: String }) error = '';
@query('.field__input') private _input!: HTMLInputElement;
updated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if (changedProperties.has('value')) { this._internals.setFormValue(this.value); this._updateValidity(); } }
get form(): HTMLFormElement | null { return this._internals.form; }
get validationMessage(): string { return this._internals.validationMessage; }
get validity(): ValidityState { return this._internals.validity; }
checkValidity(): boolean { return this._internals.checkValidity(); }
reportValidity(): boolean { return this._internals.reportValidity(); }
private _updateValidity(): void { if (this.required && !this.value) { this._internals.setValidity( { valueMissing: true }, this.error || 'This field is required.', this._input, ); } else { this._internals.setValidity({}); } }
formResetCallback(): void { this.value = ''; this._internals.setFormValue(''); }
formStateRestoreCallback(state: string): void { this.value = state; }
override focus(options?: FocusOptions): void { this._input?.focus(options); }
private _handleInput(e: Event): void { const target = e.target as HTMLInputElement; this.value = target.value; this._internals.setFormValue(this.value); }
render() { return html` <input class="field__input" type="text" .value=${live(this.value)} ?required=${this.required} ?disabled=${this.disabled} @input=${this._handleInput} /> `; }}Example 2: hx-checkbox
Section titled “Example 2: hx-checkbox”Simplified illustration (real hx-checkbox extends HelixElement + applies FormMixin, integrates with hx-checkbox-group for grouped-form suppression, and renders its own ARIA-pattern markup — see packages/hx-library/src/components/hx-checkbox/hx-checkbox.ts for the full source):
import { LitElement, html } from 'lit';import { customElement, property, query } from 'lit/decorators.js';import { live } from 'lit/directives/live.js';
@customElement('hx-checkbox')export class HelixCheckbox extends LitElement { static formAssociated = true;
private _internals: ElementInternals;
constructor() { super(); this._internals = this.attachInternals(); }
@property({ type: Boolean, reflect: true }) checked = false;
@property({ type: Boolean }) indeterminate = false;
@property({ type: String }) name = '';
@property({ type: String }) value = 'on';
@property({ type: Boolean, reflect: true }) required = false;
@property({ type: Boolean, reflect: true }) disabled = false;
@query('.checkbox__input') private _inputEl!: HTMLInputElement;
updated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if (changedProperties.has('checked') || changedProperties.has('value')) { // Submit value only if checked this._internals.setFormValue(this.checked ? this.value : null); this._updateValidity(); } }
get form(): HTMLFormElement | null { return this._internals.form; }
get validationMessage(): string { return this._internals.validationMessage; }
get validity(): ValidityState { return this._internals.validity; }
checkValidity(): boolean { return this._internals.checkValidity(); }
reportValidity(): boolean { return this._internals.reportValidity(); }
private _updateValidity(): void { if (this.required && !this.checked) { this._internals.setValidity( { valueMissing: true }, 'This field is required.', this._inputEl ?? undefined, ); } else { this._internals.setValidity({}); } }
formResetCallback(): void { this.checked = false; this.indeterminate = false; this._internals.setFormValue(null); }
formStateRestoreCallback(state: string): void { this.checked = state === this.value; }
override focus(options?: FocusOptions): void { this._inputEl?.focus(options); }
private _handleChange(): void { if (this.disabled) return;
this.indeterminate = false; this.checked = !this.checked;
this._internals.setFormValue(this.checked ? this.value : null); this._updateValidity();
this.dispatchEvent( new CustomEvent('hx-change', { bubbles: true, composed: true, detail: { checked: this.checked, value: this.value }, }), ); }
render() { return html` <label @click=${this._handleChange}> <input class="checkbox__input" type="checkbox" .checked=${live(this.checked)} .indeterminate=${live(this.indeterminate)} ?disabled=${this.disabled} ?required=${this.required} tabindex="-1" /> <span>Checkbox Label</span> </label> `; }}Example 3: hx-select
Section titled “Example 3: hx-select”Simplified illustration (real hx-select is a host-canonical combobox with a hidden native <select> fallback, its own listbox keyboard/ARIA contract, and setFormValue(null) semantics for empty selections — see packages/hx-library/src/components/hx-select/hx-select.ts for the full source):
import { LitElement, html } from 'lit';import { customElement, property, query } from 'lit/decorators.js';
@customElement('hx-select')export class HelixSelect extends LitElement { static formAssociated = true;
private _internals: ElementInternals;
constructor() { super(); this._internals = this.attachInternals(); }
@property({ type: String, reflect: true }) value = '';
@property({ type: String }) name = '';
@property({ type: Boolean, reflect: true }) required = false;
@property({ type: Boolean, reflect: true }) disabled = false;
@query('.field__select') private _select!: HTMLSelectElement;
updated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if (changedProperties.has('value')) { this._internals.setFormValue(this.value); this._updateValidity(); } }
get form(): HTMLFormElement | null { return this._internals.form; }
get validationMessage(): string { return this._internals.validationMessage; }
get validity(): ValidityState { return this._internals.validity; }
checkValidity(): boolean { return this._internals.checkValidity(); }
reportValidity(): boolean { return this._internals.reportValidity(); }
private _updateValidity(): void { if (this.required && !this.value) { this._internals.setValidity({ valueMissing: true }, 'Please select an option.', this._select); } else { this._internals.setValidity({}); } }
formResetCallback(): void { this.value = ''; this._internals.setFormValue(''); }
formStateRestoreCallback(state: string): void { this.value = state; }
override focus(options?: FocusOptions): void { this._select?.focus(options); }
private _handleChange(e: Event): void { const target = e.target as HTMLSelectElement; this.value = target.value; this._internals.setFormValue(this.value); this._updateValidity();
this.dispatchEvent( new CustomEvent('hx-change', { bubbles: true, composed: true, detail: { value: this.value }, }), ); }
render() { return html` <select class="field__select" ?required=${this.required} ?disabled=${this.disabled} @change=${this._handleChange} > <slot></slot> </select> `; }}Best Practices
Section titled “Best Practices”1. Always Declare formAssociated
Section titled “1. Always Declare formAssociated”// ✅ GOODexport class HelixInput extends LitElement { static formAssociated = true;}
// ❌ BAD: Missing declarationexport class BrokenInput extends LitElement { // attachInternals() will throw}2. Attach ElementInternals in Constructor
Section titled “2. Attach ElementInternals in Constructor”// ✅ GOODconstructor() { super(); this._internals = this.attachInternals();}
// ❌ BAD: Calling outside constructor throwsconnectedCallback() { super.connectedCallback(); this._internals = this.attachInternals(); // DOMException!}3. Update Form Value When Value Changes
Section titled “3. Update Form Value When Value Changes”// ✅ GOODupdated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if (changedProperties.has('value')) { this._internals.setFormValue(this.value); }}
// ❌ BAD: Form value out of sync with propertyupdated() { // Forgot to call setFormValue()}4. Validate on Every Relevant Change
Section titled “4. Validate on Every Relevant Change”// ✅ GOODupdated(changedProperties: Map<string, unknown>) { super.updated(changedProperties); if ( changedProperties.has('value') || changedProperties.has('required') || changedProperties.has('minlength') ) { this._updateValidity(); }}
// ❌ BAD: Only validate on value changeupdated(changedProperties: Map<string, unknown>) { if (changedProperties.has('value')) { this._updateValidity(); // Misses required/minlength changes }}5. Expose Standard Validation API
Section titled “5. Expose Standard Validation API”// ✅ GOOD: Delegate to ElementInternalsget validity(): ValidityState { return this._internals.validity;}
checkValidity(): boolean { return this._internals.checkValidity();}
reportValidity(): boolean { return this._internals.reportValidity();}
// ❌ BAD: Custom implementation that diverges from the platformget validity() { return { valid: this.value !== '' }; // Not a ValidityState!}6. Implement Both Reset and Restore Callbacks
Section titled “6. Implement Both Reset and Restore Callbacks”// ✅ GOODformResetCallback() { this.value = ''; this._internals.setFormValue('');}
formStateRestoreCallback(state: string) { this.value = state;}
// ❌ BAD: Missing callbacks breaks browser features// (no reset button support, no back/forward restoration)7. Use the Anchor Parameter for Validation UI
Section titled “7. Use the Anchor Parameter for Validation UI”// ✅ GOOD: Anchor to internal inputthis._internals.setValidity( { valueMissing: true }, 'This field is required.', this._input, // Validation tooltip appears on the input);
// ⚠️ OK but less ideal: No anchor (tooltip appears on custom element)this._internals.setValidity({ valueMissing: true }, 'This field is required.');8. Implement focus() for Label Association
Section titled “8. Implement focus() for Label Association”// ✅ GOODoverride focus(options?: FocusOptions): void { this._input?.focus(options);}
// ❌ BAD: Labels won't focus the control// (missing focus() method)9. Use null when a control should be omitted from the submission
Section titled “9. Use null when a control should be omitted from the submission”// ✅ GOOD for checkboxes: an unchecked checkbox should NOT appear in FormData,// matching native <input type="checkbox"> behavior.this._internals.setFormValue(this.checked ? this.value : null);
// ❌ Wrong here: an empty string is still a value and would submit// `agree=` rather than omitting the entry.this._internals.setFormValue(this.checked ? this.value : '');Scope this rule to controls whose empty state should be omitted (checkboxes, radio groups with no selection, file inputs with no file). Text inputs match native <input type="text"> behavior — an empty text field submits an empty string, so hx-text-input calls setFormValue(''), not setFormValue(null).
10. Reflect disabled and required for CSS
Section titled “10. Reflect disabled and required for CSS”// ✅ GOOD@property({ type: Boolean, reflect: true })disabled = false;
@property({ type: Boolean, reflect: true })required = false;
// Now :disabled and :required pseudo-classes work
// ❌ BAD: No reflection breaks CSS selectors@property({ type: Boolean })disabled = false;Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Forgetting formAssociated = true
Section titled “Pitfall 1: Forgetting formAssociated = true”// ❌ Throws DOMExceptionexport class BrokenInput extends LitElement { constructor() { super(); this._internals = this.attachInternals(); // Error! }}
// ✅ Fixedexport class FixedInput extends LitElement { static formAssociated = true;
constructor() { super(); this._internals = this.attachInternals(); }}Pitfall 2: Calling attachInternals() Outside Constructor
Section titled “Pitfall 2: Calling attachInternals() Outside Constructor”// ❌ Throws DOMExceptionconnectedCallback() { super.connectedCallback(); this._internals = this.attachInternals(); // Error!}
// ✅ Fixedconstructor() { super(); this._internals = this.attachInternals();}Pitfall 3: Not Updating Form Value
Section titled “Pitfall 3: Not Updating Form Value”// ❌ Form submission doesn't include this valueprivate _handleInput(e: Event) { this.value = (e.target as HTMLInputElement).value; // Forgot to call setFormValue()}
// ✅ Fixedprivate _handleInput(e: Event) { this.value = (e.target as HTMLInputElement).value; this._internals.setFormValue(this.value);}Pitfall 4: Calling setFormValue() in formStateRestoreCallback()
Section titled “Pitfall 4: Calling setFormValue() in formStateRestoreCallback()”// ❌ Don't call setFormValue() in restore callbackformStateRestoreCallback(state: string) { this.value = state; this._internals.setFormValue(state); // Unnecessary!}
// ✅ FixedformStateRestoreCallback(state: string) { this.value = state; // Browser already has the value}Pitfall 5: Using Empty String Instead of null
Section titled “Pitfall 5: Using Empty String Instead of null”// ❌ Checkbox always submits a value (even when unchecked)this._internals.setFormValue(this.checked ? this.value : '');
// ✅ Fixedthis._internals.setFormValue(this.checked ? this.value : null);Pitfall 6: Not Anchoring Validation UI
Section titled “Pitfall 6: Not Anchoring Validation UI”// ⚠️ Validation tooltip appears on custom element (not ideal)this._internals.setValidity({ valueMissing: true }, 'This field is required.');
// ✅ Better: Tooltip appears on the internal inputthis._internals.setValidity({ valueMissing: true }, 'This field is required.', this._input);Browser Support
Section titled “Browser Support”The ElementInternals API is supported in:
- Chrome/Edge: 77+
- Firefox: 93+
- Safari: 16.4+
For older browsers, use a polyfill:
npm install element-internals-polyfillimport 'element-internals-polyfill';The polyfill provides a close approximation of the platform API but not full parity — see the element-internals-polyfill README for documented limitations around form submission edge cases, focus delegation, and the runtime behavior on legacy browsers. Treat it as best-effort coverage rather than a universal-browser guarantee.
Summary
Section titled “Summary”Form-associated custom elements bring parity between custom components and built-in form controls. The ElementInternals API gives you:
- Form value management via
setFormValue() - Constraint validation via
setValidity(),checkValidity(),reportValidity() - Form lifecycle via
formResetCallback()andformStateRestoreCallback() - Automatic serialization in
FormDataand form submissions - Accessibility integration with browser validation UI and assistive technologies
Follow these patterns:
- Declare
static formAssociated = true - Attach
ElementInternalsin the constructor - Update form value whenever the component value changes
- Validate on every relevant property change
- Implement reset and restore callbacks
- Expose the standard validation API
- Use
nullfor empty/unchecked controls - Anchor validation UI to internal inputs
- Reflect
disabledandrequiredfor CSS
With these fundamentals, your form components participate cleanly in native HTML forms and assistive-tech accessibility trees. Framework usage carries an extra layer of integration work: @helixui/react ships 'use client' wrappers (Server Components can import them but they execute as Client Components), and SSR scenarios need framework-specific hydration guidance — read your framework’s web-component / customElements story before assuming the wrappers stream and hydrate identically to native React form controls.