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 a disabled fieldset (except those inside its first legend).
  • 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.

How each attribute affects participation A disabled control and every control in a disabled fieldset are not focusable, not submitted and not validated. A readonly control is focusable and submitted but not validated. A hidden or display none control is not focusable but is submitted and validated, which can block submit invisibly. An inert control is the same as hidden for participation. An input of type hidden is submitted and never validated. Attribute Focusable Submitted Validated natively disabled no no no inside fieldset[disabled] no no no readonly yes yes no hidden / display:none no yes yes: invisible blocker inert subtree no yes yes: invisible blocker type=hidden no yes never

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

  1. 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.
  2. Use :disabled matching, not the disabled property. The property is false for a control whose ancestor fieldset is disabled; el.matches(":disabled") is true, which is what the browser uses.
  3. 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.
  4. 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.
  5. Keep readonly values in the payload. Converting “locked” fields to disabled for styling drops them from the submission; style [readonly] instead.
  6. 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.
A disabled field that the schema still validates The user switches account type to personal, and the company name field becomes disabled. On submit, the browser's FormData excludes the disabled control. The form library, however, validates its own state object, which still contains an empty company name, and the schema's required rule fails. The user sees an error on a field they cannot edit. Validating the FormData-derived object instead lets submit proceed. User DOM / FormData State + schema account type: personal; companyName disabled submit FormData: no companyName state still has companyName "": required fails fix: validate the FormData object

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.

Should this field be validated? If the field matches :disabled, directly or through a disabled fieldset, it is neither validated nor submitted. If it is readonly or an input of type hidden, it is submitted but not shown user-facing errors. If it is not relevant given the current values, it is skipped by validation and omitted from the payload. Otherwise it is validated and submitted normally. Matches :disabled? Skip and omit yes no readonly or type=hidden? Submit, do not validate yes no Irrelevant given current values? Skip and omit yes no Validate and submit The normal case.

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.


Related

← Form Validation Lifecycle