Conditional Field Validation Based on Selection

Make a field required and validated only when another input — usually a <select> — holds a specific value, and skip its constraints entirely whenever it is hidden, so the user is never blocked by a rule for a field they cannot even see.

Conditional forms are everywhere: “Other (please specify)”, a shipping address that appears only when “ship to a different address” is checked, a company-name field that matters only for “Employed”. The failure mode is always the same — a constraint left active on a hidden field silently blocks submission with an error the user can never reach. This recipe toggles both visibility and the constraint together, driven by a single source of truth, so the two can never drift apart. It is the field-level companion to the broader progressive disclosure techniques that govern when interface elements appear at all.

When to Use This Recipe

Use conditional validation when a field’s relevance depends on another input’s value:

  • A “specify other” text field that only matters when a select is set to other.
  • A field group revealed by a radio choice or checkbox (employment status, account type, delivery method).
  • Any input that is sometimes required and sometimes irrelevant within the same form.

The governing rule: a hidden field must contribute nothing to validity. If the user cannot see or reach it, its constraints must be inert.

Selection-driven conditional validation A select element drives a dependent field. When the select equals the trigger value, the field becomes visible and required. When it equals any other value, the field is hidden, its value cleared, and its required and pattern constraints removed. select changes value = ? = trigger ≠ trigger show + required validate normally hide + clear remove constraints always valid never blocks submit
The select drives the dependent field: matching the trigger shows and requires it; any other value hides it, clears its value, and strips its constraints so it can never block submission.

Step 1 — Markup with a Trigger and Dependent Field

<form id="profile" novalidate>
  <label for="role">Role</label>
  <select id="role" name="role" required>
    <option value="">Select a role…</option>
    <option value="employee">Employee</option>
    <option value="contractor">Contractor</option>
    <option value="other">Other</option>
  </select>

  <!-- Shown and required only when role === "other" -->
  <div id="other-row" hidden>
    <label for="other-role">Please specify</label>
    <input id="other-role" name="otherRole" type="text" aria-describedby="other-role-error" />
    <p id="other-role-error" class="field-error" role="status" aria-live="polite" hidden></p>
  </div>

  <button type="submit">Save</button>
</form>

The dependent row starts hidden, and the input carries no required or pattern in static markup — constraints are applied only when the field becomes relevant.

Step 2 — Toggle Visibility and Constraints Together

Keep a single function that owns both the DOM visibility and the constraint state, driven by the trigger’s current value. Calling it on change guarantees they never diverge.

const form = document.querySelector<HTMLFormElement>("#profile")!;
const role = form.elements.namedItem("role") as HTMLSelectElement;
const otherRow = document.getElementById("other-row")!;
const otherInput = form.elements.namedItem("otherRole") as HTMLInputElement;

function syncOtherField(): void {
  const active = role.value === "other";

  otherRow.hidden = !active;

  if (active) {
    otherInput.required = true;
    otherInput.pattern = ".{2,}"; // at least 2 characters when relevant
  } else {
    // Hidden → contribute nothing to validity.
    otherInput.required = false;
    otherInput.removeAttribute("pattern");
    otherInput.value = "";                 // clear stale input
    setFieldError(otherInput, null);       // clear any leftover message
  }
}

role.addEventListener("change", syncOtherField);
syncOtherField(); // run once so initial state is correct (e.g. on back-navigation)

Disabling the control is an alternative to removing constraints — a disabled field is excluded from validation and from the submitted FormData. Use disabled when you also want the value omitted from the payload; use constraint removal plus hidden when you want to keep the row in the layout flow but inert.

Step 3 — Validate Only What Is Relevant on Submit

Because hidden fields have no constraints, the standard manual pass simply does the right thing — there is no special-casing in the submit handler.

function setFieldError(input: HTMLInputElement, message: string | null): void {
  const el = document.getElementById(`${input.name}-error`);
  input.setAttribute("aria-invalid", String(message !== null));
  if (el) {
    el.textContent = message ?? "";
    el.hidden = message === null;
  }
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  let firstInvalid: HTMLInputElement | null = null;

  for (const el of form.querySelectorAll<HTMLInputElement>("input, select")) {
    // checkValidity() returns true for a constraint-free hidden field.
    const ok = el.checkValidity();
    setFieldError(el, ok ? null : el.validationMessage);
    if (!ok && !firstInvalid) firstInvalid = el;
  }

  if (firstInvalid) firstInvalid.focus();
  else void save(new FormData(form));
});

Option Reference

Technique When relevant Excluded from validity? Excluded from FormData?
hidden + remove required/pattern Keep row in markup, inert ✅ (no constraints) ❌ (still submitted, but empty)
disabled the field Omit value entirely
Remove the node from the DOM Field truly never applies
Re-run syncOtherField on load Restore correct state after back-navigation n/a n/a

Verification Steps

Edge Cases & Failure Modes

Stale value submitted from a hidden field. If the user fills the dependent field, then changes the select away from the trigger, the old value can still be sent. Clear value whenever the field becomes irrelevant, as shown.

Constraint left active on a hidden field. This is the classic bug: the field is visually hidden but still required, so form.checkValidity() returns false with an error pointing at an element the user cannot see, and the form silently refuses to submit. Always remove the constraint when hiding.

Forgetting the initial sync. On first load — or when a browser restores form state after back-navigation — the dependent field may already match the trigger. Call syncOtherField() once at startup so constraints and visibility reflect the restored value rather than the static markup.

Scaling to Many Dependent Fields with a Declarative Rule Table

The single-function approach in Step 2 is ideal for one dependent field, but real forms often have several — a “contractor” role reveals a tax-ID field, “employee” reveals a start-date, and “other” reveals a free-text box. Writing a bespoke syncOtherField for each one duplicates the show/hide/clear logic and invites the two states to drift apart. A declarative rule table keeps every dependency in one readable place and drives them all through a single reconcile pass.

interface DependencyRule {
  /** The dependent row (toggled via the hidden attribute). */
  row: HTMLElement;
  /** The control whose constraints we mutate. */
  field: HTMLInputElement;
  /** Predicate deciding whether the field is currently relevant. */
  when: (triggerValue: string) => boolean;
  /** Constraints applied only while the field is active. */
  constraints?: { required?: boolean; pattern?: string };
}

function buildRule(rowId: string, fieldName: string, rule: Omit<DependencyRule, "row" | "field">): DependencyRule {
  const row = document.getElementById(rowId);
  const field = form.elements.namedItem(fieldName);
  if (!row || !(field instanceof HTMLInputElement)) {
    throw new Error(`Dependency wiring failed for ${rowId} / ${fieldName}`);
  }
  return { row, field, ...rule };
}

const rules: DependencyRule[] = [
  buildRule("other-row", "otherRole", {
    when: (v) => v === "other",
    constraints: { required: true, pattern: ".{2,}" },
  }),
  buildRule("taxid-row", "taxId", {
    when: (v) => v === "contractor",
    constraints: { required: true, pattern: "\\d{9}" },
  }),
];

function reconcile(): void {
  const value = role.value;
  for (const { row, field, when, constraints } of rules) {
    const active = when(value);
    row.hidden = !active;

    if (active && constraints) {
      if (constraints.required) field.required = true;
      if (constraints.pattern) field.pattern = constraints.pattern;
    } else {
      field.required = false;
      field.removeAttribute("pattern");
      field.value = "";
      setFieldError(field, null);
    }
  }
}

role.addEventListener("change", reconcile);
reconcile();

Because every rule flows through the same branch, there is exactly one place where a field can be made inert — so the “hidden but still required” bug becomes structurally impossible rather than something you must remember to guard against per field. The buildRule guard also fails loudly at startup if a rowId or fieldName is mistyped, turning a silent no-op into an immediate error.

Announcing the Reveal to Assistive Technology

Toggling the hidden attribute correctly removes the row from the accessibility tree, but a sighted user sees the new field appear while a screen-reader user gets no signal that the form just grew. Wrap the dependent region — not the individual error node — in a container with aria-live="polite" so the reveal itself is announced, and move focus deliberately only when the selection was made with the keyboard.

function revealAndAnnounce(row: HTMLElement, field: HTMLInputElement, active: boolean): void {
  row.hidden = !active;
  // Do NOT auto-focus on every reveal: yanking focus mid-typing is disorienting.
  // Focus only when the trigger itself still holds focus (a deliberate selection).
  if (active && document.activeElement === role) {
    field.focus({ preventScroll: false });
  }
}

Point the trigger <select> at the region it controls with aria-controls, and set aria-expanded on it to mirror the revealed state. This tells assistive technology that choosing an option changes another part of the form — context a purely visual reveal never conveys. Keep the aria-live region present in the DOM at all times (only its contents toggle); a region that is itself added and removed from the tree will not reliably announce, because the live region must exist before the content it should read out.

Frequently Asked Questions

Should I use hidden, disabled, or remove the constraints?

If you want the field's value omitted from the submission entirely, disabled is cleanest — a disabled control is skipped by both constraint validation and FormData. If you want to keep the row visible-in-markup but inert, hide it and remove required/pattern, and clear its value. The one thing you must never do is leave a constraint active on a field the user cannot reach.

Why does my form refuse to submit with no visible error?

Almost always a hidden field that is still required. Constraint validation runs against every control regardless of visibility, so a hidden required field makes the form invalid while its error message has nowhere to display. Removing the constraint whenever you hide the field — and clearing its value — resolves it.

Do I need to re-validate when the dependent field appears?

Not immediately — applying required the moment the field appears, before the user has touched it, would flash a premature error. Add the constraint on reveal but defer the message until the field is touched or the form is submitted, consistent with gating validation behind a touched state. The submit pass then catches it if the user leaves it blank.

How do I handle several fields that each depend on a different option?

Move the wiring into a declarative rule table and run a single reconcile pass over every rule on each change, as shown above. One shared branch decides visibility and constraints for all fields, so there is exactly one code path that can make a field inert — the classic "hidden but still required" bug can no longer hide in a forgotten per-field handler.

← Back to Progressive Disclosure Techniques