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
requiredand 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.
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.
Related Guides
- Progressive Disclosure Techniques — the broader pattern of revealing fields and constraints only when relevant.
- Cross-Field Validation Strategies — validating one field against another’s value, of which conditional requirement is one case.
- Real-Time vs On-Submit Feedback Timing — deferring a newly applied constraint’s error until the field is touched.