The browser already knows not to validate or submit a disabled control, but your schema, your state store and your custom validators do not — so a disabled “company name” field still fails required in Zod, or a readonly field that should be submitted is dropped because someone treated it like a disabled one.
The form validation lifecycle assumes a known set of fields takes part in a validation pass. That set is not “every field in state”. It is defined by several overlapping HTML attributes whose effects differ in precise ways, and every layer of the form has to apply the same definition.
Context and prerequisites
The native rules, which are the baseline everything else should match:
disabled— not focusable, not submitted, barred from constraint validation. Applies to the control and to every control inside adisabledfieldset(except those inside its firstlegend).readonly— focusable, submitted, and also barred from constraint validation. Users cannot change it, so native validation assumes there is nothing to report — but the value is sent.hidden/display: none— still submitted and still validated. A hidden required field that is empty blocks native submit with an error the browser cannot show (“An invalid form control is not focusable” in the console).inert— not focusable or interactive, but still submitted and validated like any hidden field. Inert is about interaction, not participation.type="hidden"— submitted, never validated.
So “hidden” is the dangerous case natively, and “disabled” is the dangerous case in custom code.
The core pattern: one participation function for every layer
export type Participation = { submit: boolean; validate: boolean };
// Mirrors the browser's own rules, so custom validation and native
// validation agree. Call it with the live element when one exists.
export function participation(el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement): Participation {
// matches(':disabled') covers BOTH the control's own attribute and an
// ancestor <fieldset disabled>, including the legend exception.
if (el.matches(":disabled")) return { submit: false, validate: false };
if (el instanceof HTMLInputElement && el.type === "hidden") return { submit: true, validate: false };
if ("readOnly" in el && el.readOnly) return { submit: true, validate: false };
// Hidden-by-layout and inert fields: the browser WOULD validate them, which
// is the invisible-blocker bug. Our policy: a field the user cannot see or
// reach must not block them. Decide relevance from values instead.
return { submit: true, validate: true };
}
// For schema validation, build the object to validate from participating
// fields only — then the schema never sees a disabled control's stale value.
export function participatingValues(form: HTMLFormElement): Record<string, FormDataEntryValue> {
// FormData already excludes disabled controls and includes readonly ones:
// it is the browser's own definition of "submitted".
return Object.fromEntries(new FormData(form));
}
export function participatingForValidation(form: HTMLFormElement): string[] {
return (Array.from(form.elements) as HTMLInputElement[])
.filter((el) => el.name && participation(el).validate)
.map((el) => el.name);
}
For controlled forms without live elements, keep a disabled flag per field in state and apply the same table: disabled fields are omitted from the validated object and the payload; readonly fields are included in the payload and skipped by user-facing validators.
Step-by-step walkthrough
- Adopt the native table as your contract. Disabled means “not part of the form”; readonly means “part of the form, not editable”; hidden means nothing by itself.
- Use
:disabledmatching, not thedisabledproperty. The property is false for a control whose ancestorfieldsetis disabled;el.matches(":disabled")is true, which is what the browser uses. - Build validation input from participating fields.
new FormData(form)gives the browser’s submit set; validate that object with your schema so disabled values are simply absent. - Never rely on hidden to exclude. A hidden field must be excluded by relevance — the rule in what happens to errors when a field is hidden — or by disabling it at the same time as hiding it.
- Keep readonly values in the payload. Converting “locked” fields to
disabledfor styling drops them from the submission; style[readonly]instead. - Make the schema tolerate absence. Fields that can be disabled must be optional in the schema, or modelled with a discriminated union keyed on whatever disables them.
Failure modes and edge cases
1. “An invalid form control is not focusable”
That console message means native validation found an invalid control it cannot show — almost always a required field inside a collapsed or display: none container. Either remove required while it is hidden, disable it while hidden, or add novalidate and handle validation yourself.
2. Disabled submit buttons and disabled forms
Disabling every control during submission — a common “prevent double submit” trick — also removes them from any FormData built after disabling. Build the payload first, then disable. Better still, keep controls enabled and guard the submit, as in handling double submit and idempotency.
3. Custom elements
A form-associated custom element participates according to its own ElementInternals validity and formDisabledCallback. It is disabled by an ancestor fieldset only if it implements that callback — see form-associated custom elements with ElementInternals.
4. Accessibility of disabled fields
Disabled fields are skipped by Tab and announced as “dimmed” or “unavailable” when reached by virtual cursor. If a user needs to read a value that cannot be edited (an account number, a locked price), use readonly, which stays focusable and readable.
5. Server-side assumptions
If the server expects a field that the client disables, submission fails with a 422 the user cannot fix. The participation contract must be shared: the server should treat an absent disabled field as intentionally absent, not as missing.
Verification checklist
Frequently Asked Questions
Why does the browser skip readonly fields in validation?
Because the user cannot change them, an error on a readonly field is unactionable. The HTML specification bars readonly controls from constraint validation for that reason, while still submitting their values. If a readonly value can be wrong, validate it on the server.
Should I use aria-disabled instead of disabled?
aria-disabled="true" keeps the control focusable and in the submission but tells assistive technology it is unavailable. It is useful for buttons where you want users to discover why an action is unavailable. For inputs, it does not exclude the value, so you must also handle participation in code.
Does fieldset disabled affect buttons inside it?
Yes. Every form-associated element inside a disabled fieldset is disabled, including buttons, except those inside the fieldset’s first legend. That makes a disabled fieldset a convenient way to lock a whole section, and a surprising way to lose a section’s submit button.