HomeJournalThis post

Build Form-Associated Custom Elements

Build form-associated custom elements with one value model for ElementInternals, validation, reset, restore, disabled state, and native fallback.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Form-associated custom elements should submit, validate, reset, restore, and disable as predictably as the native fields they extend. This tutorial builds that lifecycle around ElementInternals and keeps a complete native fallback when the platform contract is unavailable.

Form-associated custom elements need native contracts

Form-associated custom elements let custom form controls participate in a form through ElementInternals rather than simulating submission with hidden inputs alone. The element must opt in, establish one source of truth for its value, expose a usable accessible interface, and handle disabled, reset, restore, and validation behavior. Styling a shadow-root widget is the easy part; matching the form lifecycle is the real component contract.

Start with the smallest useful control: a single-value rating field named “satisfaction.” It exposes five ordinary buttons in its shadow root, reflects the chosen value, and sends that value with FormData. The host receives the field name and label relationship; the internal buttons receive keyboard and pressed-state behavior. The first figure maps the form owner, custom-element host, ElementInternals, shadow controls, and submitted entry.

The downloadable artifact is a deterministic Node model of that lifecycle. It does not claim that Node implements ElementInternals or replace browser and assistive-technology testing. It proves the state projection used by the tutorial: selected value, submitted entry, validity, disabled state, reset value, restoration, and fail-closed fallback decision.

Write the server contract at the same time. A custom rating still arrives as an ordinary name-value pair, still requires server validation, and still needs an error mapped back to the field. Browser participation improves the form experience; it never turns client state into trusted data or changes the submission protocol. Include the field name, accepted values, empty-value rule, repeated-name behavior, and stable error code in that contract. Test that contract with an ordinary native form before adding the custom surface.

Opt into form association before construction

The HTML custom-elements specification defines form-associated custom elements. A class opts in with a static formAssociated value, then calls attachInternals during construction. Keep the returned ElementInternals private and never call attachInternals twice. The platform can then expose the owning form and lifecycle callbacks to the element.

Construction should create structure, not assume connection or a form owner. The element may be upgraded before or after insertion, moved between forms, disabled by an ancestor fieldset, or restored by session history. Form-associated custom elements should derive current state inside the relevant callback instead of capturing one initial DOM arrangement. The form-owner diagram deliberately includes an external form attribute path as well as ancestry.

Name and labelling still matter. Use the host’s accessible-name contract and ensure the shadow control communicates role, current value, and interaction. The guide to accessible names and descriptions covers the distinction between visible labels and computed names. Do not duplicate the label inside shadow DOM in a way that causes repeated announcements; inspect the accessibility tree in target browsers.

Connect setFormValue to one value model

ElementInternals.setFormValue supplies the value that form submission will include. MDN’s setFormValue reference describes a value and an optional state used during restoration. For a single-value rating, call setFormValue with the selected string, or null when the control should contribute no entry. Avoid maintaining a hidden input and ElementInternals simultaneously in the supported path; duplicate names can silently submit twice.

Keep selection in one setter. It validates the domain, updates internal state, calls setFormValue, refreshes pressed and focus indicators, and updates validity. User click, keyboard selection, attribute initialization, form restore, and reset should all call this setter with an explicit cause. Form-associated custom elements become testable when every path reaches the same projection instead of mutating scattered properties.

The tutorial uses a closed set from one through five. Empty is permitted until required applies; any other string is rejected and clears stale submission state. The artifact tests valid, empty, and hostile values. For a multi-value control, FormData can represent repeated entries, but document serialization, restore state, and server parsing together before choosing that shape.

Form owner and custom element DOM mapA form owns a rating-element host. The host connects ElementInternals to a successful FormData entry and contains five shadow-root buttons with one pressed value.<form id="survey"><x-rating name="satisfaction">shadow root☆ ☆ ★ ☆ ☆ElementInternalsFormDatasatisfaction = 3
The host owns form association; internal buttons own interaction; one normalized value feeds validity and FormData.

Semantic equivalent: the form relationships are listed below.

Form owner
Survey form owns x-rating named satisfaction.
Shadow control
Five rating buttons expose value 3 as selected.
Submission
ElementInternals contributes exactly one entry: satisfaction=3.
  • The labels and values repeat every relationship encoded by position.

Implement disabled, reset, and restore callbacks

The form-control infrastructure section of the HTML Standard defines the platform concepts around form controls and association. A form-associated element may receive formAssociatedCallback, formDisabledCallback, formResetCallback, and formStateRestoreCallback. The exact browser invocation is platform behavior; your component’s response should remain small and deterministic.

When disabled, remove the control from interaction, reflect an unavailable visual state, and ensure it contributes no successful entry. Preserve the selected value internally if re-enabling should restore it, but do not submit it while disabled. Reset returns to the declared default, not necessarily empty. Restore accepts the state previously supplied to setFormValue, validates it, and refuses malformed values without leaving an old submitted entry active.

The second figure is a lifecycle rail with reset, restore, disabled, and submit branches. It distinguishes callbacks from user events by shape, and it labels which transitions update value versus availability. Pair this with forms that respect user time so session restore and accidental navigation do not erase meaningful work. A component is native-like only when these less-visible paths are designed, not merely when clicking submits once.

Wire constraint validation without fake errors

Use ElementInternals.setValidity to project constraint validation states. For a required rating with no selection, set valueMissing and a concise message, optionally anchored to a focusable internal control. When valid, call setValidity with an empty flags object. Clear stale errors whenever selection or required changes. Form-associated custom elements should not keep a red error visible after the underlying validity becomes true.

Do not invent a parallel validity engine disconnected from the submitted value. The same normalized value should determine setFormValue, valueMissing, component state, and the server payload. Native checkValidity and reportValidity behavior can then participate, while your interface decides where explanatory text appears. Browser support and announcement behavior still require hands-on testing.

For composite widgets, keyboard interaction is a separate responsibility. A star rating can use a radiogroup pattern or another semantically appropriate control; a searchable picker is substantially more complex. Review accessible combobox behavior with active descendants before combining remote filtering with form association. ElementInternals does not supply arrow-key, focus, popup, or option semantics on behalf of the component.

Form-associated custom element lifecycleA central value state branches to submit, reset, restore, and disabled callbacks. Submit contributes the value, reset restores default two, restore validates state four, and disabled omits the field.VALUE = 3valid · enabledSUBMITentry 3RESETdefault 2RESTOREvalidate state 4DISABLEDomit entry
Every lifecycle callback passes through one value projection, so visible state, validity, and submission stay aligned.
Submit
Enabled value 3 contributes one entry.
Reset
Value returns to declared default 2.
Restore
Stored state 4 is validated before projection.
Disabled
Interaction and successful submission are removed.

Design a truthful unsupported fallback

Support detection should test the capability actually required, not only customElements. If attachInternals or the form-associated contract is unavailable, choose a fallback before defining the enhanced element. A light-DOM native select is the strongest baseline for a rating because it submits, resets, disables, validates, and works without JavaScript. Form-associated custom elements should enhance that stable field, not delete it before support is known.

One strategy keeps the native select as the canonical control and progressively decorates it. Another uses ElementInternals in supported browsers and renders a native sibling only in the fallback path. Do not keep both successful under the same name. The third figure compares supported, unsupported, no-JavaScript, disabled, restore, and invalid states with explicit submission outcomes.

This ownership choice resembles the customizable select versus headless combobox decision: prefer the smallest complete contract. A custom element is justified when reusable presentation and behavior outweigh lifecycle ownership. If the team cannot test submission, reset, disabled fieldsets, autofill or restoration, accessible naming, keyboard interaction, and server validation, ship the native field.

Runnable artifact — Deterministic state projection; not browser ElementInternals or accessibility conformance evidence.

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
const sha=value=>createHash("sha256").update(JSON.stringify(value)).digest("hex");
function project(state){const normalized=["1","2","3","4","5"].includes(state.value)?state.value:"";const valueMissing=state.required&&!normalized;return{...state,value:normalized,validity:{valueMissing},formEntry:state.disabled||!normalized?null:{name:state.name,value:normalized},message:valueMissing?"Choose a rating from 1 to 5.":""}}
function reduce(raw,event){let state={...raw};if(!["connect","set","required","disable","reset","restore","submit"].includes(event.type))throw new Error("unknown-event");if(event.type==="connect")state.supported=event.attachInternals===true;else if(event.type==="set")state.value=String(event.value??"");else if(event.type==="required")state.required=event.value===true;else if(event.type==="disable")state.disabled=event.value===true;else if(event.type==="reset")state.value=state.defaultValue;else if(event.type==="restore")state.value=["1","2","3","4","5"].includes(String(event.state))?String(event.state):"";const projected=project(state);return{...projected,renderOwner:projected.supported?"ElementInternals":"native-select",submitted:event.type==="submit"&&!projected.validity.valueMissing?projected.formEntry:null}}
const valueIndex=process.argv.indexOf("--value"),value=valueIndex>=0?(process.argv[valueIndex+1]??"5"):"3",events=[{type:"connect",attachInternals:!process.argv.includes("--unsupported")},{type:"required",value:true},{type:"set",value},{type:"submit"},{type:"disable",value:true},{type:"submit"},{type:"disable",value:false},{type:"reset"},{type:"restore",state:"4"},{type:"submit"}];let state={name:"satisfaction",value:"",defaultValue:"2",required:false,disabled:false,supported:false};const trace=[];for(const event of events){state=reduce(state,event);trace.push({event,state})}
const malformed=reduce(state,{type:"restore",state:"hostile"});assert.equal(malformed.value,"");assert.equal(malformed.formEntry,null);const empty=reduce(reduce({...state,value:""},{type:"required",value:true}),{type:"submit"});const hostile={unknown:""};try{reduce(state,{type:"mutate"})}catch(error){hostile.unknown=error.message}
const core={schema:"form-associated-control-model-v1",fixture:"synthetic rating reducer",events,trace,final:state,boundaries:{requiredEmpty:empty,malformedRestore:malformed,disabledEntry:trace[5].state.formEntry,fallbackOwner:reduce(state,{type:"connect",attachInternals:false}).renderOwner},hostile,claimBoundary:"Application state projection only; not browser ElementInternals conformance, accessibility-tree, autofill, or assistive-technology evidence."};console.log(JSON.stringify({...core,receiptHash:sha(core)},null,2));console.log("PASS: custom-control value, validity, submit, disabled, reset, restore, fallback, hostile input, and digest verified");

Run the custom form control lifecycle model

The public Node artifact models the rating control as a pure reducer over a synthetic fixture. Events include connect, set, required, disable, reset, restore, and submit. Each step emits value, default value, disabled state, validity flags, contribution to FormData, and a status announcement. Form-associated custom elements in the model fail closed: unknown restore state clears the successful entry, and unsupported capability selects the native fallback.

The independent suite executes identical event streams twice, then changes the selected rating and expects a different receipt. It tests empty required submission, exact reset behavior, disabled omission, valid restoration, malformed restoration, unknown events, and unsupported capability. It recomputes the expected FormData entry from disabled and normalized value, rather than accepting a top-level pass flag.

This fixture proves application logic, not platform conformance. It cannot invoke browser callbacks, construct an accessibility tree, or demonstrate autofill behavior. Use it as the deterministic core beneath Playwright and assistive-technology checks. The boundary is printed in every receipt so a passing reducer cannot be mistaken for proof that a shipped browser component is accessible.

Native and custom-element fallback behavior matrixA matrix compares supported custom element, unsupported native select, no JavaScript native select, disabled controls, restored controls, and invalid required controls across rendering and submission.StateRendered ownerForm entryValiditySupportedElementInternals ◆one custominternalsUnsupportedselect ●one nativenativeNo JavaScriptselect ●one nativenativeDisabledeither ◇noneomittedRequired emptyactive !blockedvalueMissingInvariant: exactly one successful field owner; stale value clears on malformed restore.
The enhancement is truthful only when supported and fallback paths each provide exactly one complete form control.
Semantic data for this figure
StateOwnerSubmitted entryValidation
SupportedElementInternalsOne customInternals
Unsupported or no JSNative selectOne nativeNative
DisabledEitherNoneOmitted
Required emptyActive ownerBlockedvalueMissing

Test form-associated custom elements as forms

A release matrix should cover parser upgrade timing, programmatic creation, form ancestry and the form attribute, required empty submission, valid submission, disabled host, disabled fieldset, reset, history restore, removal and reconnection, duplicate names, and server parsing. Add keyboard and screen-reader passes for the chosen interaction pattern. Form-associated custom elements are successful when the surrounding form cannot tell that lifecycle responsibilities were skipped.

Inspect the submitted FormData directly. Verify that unsupported mode contributes exactly one native entry, supported mode contributes exactly one internals entry, and disabled mode contributes none. Change an input after a successful run and ensure any downloadable receipt or debug view becomes stale until rerun. Treat browser differences as named support results rather than quietly falling through to a broken custom path.

Build the smallest control first. Run the lifecycle model, implement the rating element from the same state transitions, and compare its browser trace with the receipt. Once every submit, reset, restore, disable, and invalid branch agrees, the component has earned its custom presentation.