Skip to content
HELiX

Form Participation Fundamentals

apps/docs/src/content/docs/components/forms/fundamentals Click to copy
Copied! apps/docs/src/content/docs/components/forms/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.

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 FormData and URLSearchParams

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.

At a high level, making a custom element form-associated involves:

  1. Declare static formAssociated = true on your class
  2. Attach ElementInternals in the constructor via this.attachInternals()
  3. Set form value via this._internals.setFormValue()
  4. Implement validation via this._internals.setValidity()
  5. 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.

ElementInternals is the bridge between your custom element and the browser’s form APIs. It provides methods for:

  • Form value managementsetFormValue()
  • ValidationsetValidity(), checkValidity(), reportValidity()
  • Form accessform property
  • Accessibility — ARIA role and state management (covered in accessibility docs)

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 = true enables 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 like formAssociatedCallback / formResetCallback when static formAssociated = true is declared.
  • HELiX shortcut. Inside @helixui/library, components extend HelixElement and access this._internals — a lazy accessor that calls attachInternals() on first read. Form-participating subclasses additionally set static override formAssociated = true. You don’t write the constructor / private field yourself.

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 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.

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); // undefined

This is rarely needed, but useful for debugging or generic form utilities.

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;
}
}
connectedCallback() {
super.connectedCallback();
if (this._internals.form) {
console.log('Part of form:', this._internals.form.id);
} else {
console.log('Standalone (not in a form)');
}
}
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');
};
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;
}

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.

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.

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 to value)

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>

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>

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} /> `;
}
}

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>

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 submits
  • state — What the browser saves for restoration (defaults to value)

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-associated custom elements participate in the browser’s form state lifecycle. This includes:

  1. Form reset — User clicks <button type="reset"> or calls form.reset()
  2. Form state restoration — Browser back/forward navigation or session restore

Called when the form is reset. Your component should restore its default state.

formResetCallback(): void {
this.value = '';
this._internals.setFormValue('');
}
export class HelixTextInput extends LitElement {
@property({ type: String })
value = '';
formResetCallback() {
this.value = ''; // Reset to default
this._internals.setFormValue('');
}
}
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);
}
}
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 valuethis.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

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 to setFormValue() (or the value if no state was provided). May be null when the browser restores without a previously saved value.
  • mode — Either 'restore' (back/forward) or 'autocomplete' (autocomplete feature)
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;
}
}
}
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;
}
}
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 shape
formDisabledCallback(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 pattern
protected override _onFormDisabled(disabled: boolean): void {
this.disabled = disabled;
}

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">.

ElementInternals exposes three validation methods:

// Set the validity state
setValidity(
flags: ValidityStateFlags,
message?: string,
anchor?: HTMLElement
): void;
// Check if valid (without showing UI)
checkValidity(): boolean;
// Check if valid (with browser validation UI)
reportValidity(): boolean;

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
}

Use setValidity() to mark the element as valid or invalid:

this._internals.setValidity({}); // Empty object = valid
if (this.required && !this.value) {
this._internals.setValidity(
{ valueMissing: true },
'This field is required.',
this._inputElement,
);
}
if (this.value.length > 0 && this.value.length < 3) {
this._internals.setValidity(
{ customError: true },
'Must be at least 3 characters.',
this._inputElement,
);
}
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({});
}
setValidity(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement): void;
  • flags — Which validation constraints are violated
  • message — Error message for validationMessage property
  • anchor — 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 input
this._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.');

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 should run at these key points:

  1. On value change — Update validity when the value changes
  2. On property change — Update validity when required, minlength, etc. change
  3. On blur — Optionally show validation errors only after the user leaves the field
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({});
}
}

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:

  1. Calls checkValidity() on the <hx-text-input>
  2. checkValidity() returns false
  3. Browser blocks submission and focuses the invalid field
  4. Browser shows validation UI (if reportValidity() was called)

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 UI
if (!input.reportValidity()) {
console.log('Invalid and UI shown');
}

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();
}
}
}

When a form is submitted, the browser serializes all form-associated elements into a FormData object or URL-encoded string.

<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>
const form = document.querySelector('#my-form');
const formData = new FormData(form);
const params = new URLSearchParams(formData);
console.log(params.toString()); // "username=alice&subscribe=on"
const form = document.querySelector('#my-form');
const formData = new FormData(form);
fetch('/api/submit', {
method: 'POST',
body: formData, // Browser serializes automatically
});

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.

Form-associated custom elements work seamlessly with all native form features.

<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 /submit with username and email
  • If invalid, submission is blocked and browser shows validation UI
<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

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 elements

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:

  1. Implements focus() method
  2. 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);
}
}

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 :disabled CSS 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.

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}
/>
`;
}
}

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>
`;
}
}

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>
`;
}
}
// ✅ GOOD
export class HelixInput extends LitElement {
static formAssociated = true;
}
// ❌ BAD: Missing declaration
export class BrokenInput extends LitElement {
// attachInternals() will throw
}
// ✅ GOOD
constructor() {
super();
this._internals = this.attachInternals();
}
// ❌ BAD: Calling outside constructor throws
connectedCallback() {
super.connectedCallback();
this._internals = this.attachInternals(); // DOMException!
}
// ✅ GOOD
updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('value')) {
this._internals.setFormValue(this.value);
}
}
// ❌ BAD: Form value out of sync with property
updated() {
// Forgot to call setFormValue()
}
// ✅ GOOD
updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
if (
changedProperties.has('value') ||
changedProperties.has('required') ||
changedProperties.has('minlength')
) {
this._updateValidity();
}
}
// ❌ BAD: Only validate on value change
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('value')) {
this._updateValidity(); // Misses required/minlength changes
}
}
// ✅ GOOD: Delegate to ElementInternals
get 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 platform
get validity() {
return { valid: this.value !== '' }; // Not a ValidityState!
}

6. Implement Both Reset and Restore Callbacks

Section titled “6. Implement Both Reset and Restore Callbacks”
// ✅ GOOD
formResetCallback() {
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 input
this._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”
// ✅ GOOD
override 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).

// ✅ 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;

Pitfall 1: Forgetting formAssociated = true

Section titled “Pitfall 1: Forgetting formAssociated = true”
// ❌ Throws DOMException
export class BrokenInput extends LitElement {
constructor() {
super();
this._internals = this.attachInternals(); // Error!
}
}
// ✅ Fixed
export 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 DOMException
connectedCallback() {
super.connectedCallback();
this._internals = this.attachInternals(); // Error!
}
// ✅ Fixed
constructor() {
super();
this._internals = this.attachInternals();
}
// ❌ Form submission doesn't include this value
private _handleInput(e: Event) {
this.value = (e.target as HTMLInputElement).value;
// Forgot to call setFormValue()
}
// ✅ Fixed
private _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 callback
formStateRestoreCallback(state: string) {
this.value = state;
this._internals.setFormValue(state); // Unnecessary!
}
// ✅ Fixed
formStateRestoreCallback(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 : '');
// ✅ Fixed
this._internals.setFormValue(this.checked ? this.value : null);
// ⚠️ Validation tooltip appears on custom element (not ideal)
this._internals.setValidity({ valueMissing: true }, 'This field is required.');
// ✅ Better: Tooltip appears on the internal input
this._internals.setValidity({ valueMissing: true }, 'This field is required.', this._input);

The ElementInternals API is supported in:

  • Chrome/Edge: 77+
  • Firefox: 93+
  • Safari: 16.4+

For older browsers, use a polyfill:

Terminal window
npm install element-internals-polyfill
import '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.

Form-associated custom elements bring parity between custom components and built-in form controls. The ElementInternals API gives you:

  1. Form value management via setFormValue()
  2. Constraint validation via setValidity(), checkValidity(), reportValidity()
  3. Form lifecycle via formResetCallback() and formStateRestoreCallback()
  4. Automatic serialization in FormData and form submissions
  5. Accessibility integration with browser validation UI and assistive technologies

Follow these patterns:

  • Declare static formAssociated = true
  • Attach ElementInternals in 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 null for empty/unchecked controls
  • Anchor validation UI to internal inputs
  • Reflect disabled and required for 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.