"An invalid form control is not focusable" killed submit
This article may contain affiliate links. Its content is not affected by advertising.
In short
When a required field sits inside a section hidden with the hidden attribute, the browser can't show which field is empty, so it blocks submit silently instead of showing an error.
The short version
A three-step application form was built as a single <form>, with each step shown or hidden using the hidden attribute (equivalent to display:none). The required fields live on step 1; the submit button lives on step 2. Step 1’s “Continue to confirmation” button is type="button" and validates nothing, so a user can reach step 2 with required fields still empty. Pressing the step 2 submit button (type="submit") then triggers the browser’s native validation, which finds the empty required fields — but by then step 1’s section is hidden and unfocusable, so the browser has no way to surface an error and silently aborts the submit. The fix: add noValidate to the form to disable native validation, and run an explicit check inside “Continue to confirmation” that renders an error message on the spot instead.
Symptom
The /apply form for signing up for the school-advertising support program has three steps — choose a school (step 0), enter listing details and contact info (step 1), and confirm and submit (step 2) — driven by a step value in useState. All three steps’ inputs live inside a single <form action={formAction}>, and which step is visible is decided purely by wrapping each step in a <section> with the hidden attribute.
<form action={formAction}>
{/* STEP 0: choose school + period */}
<section hidden={step !== 0}>...</section>
{/* STEP 1: listing content (required contact fields live here) */}
<section hidden={step !== 1}>
...
<input id="sf_name" name="name" required maxLength={200} ... />
<input id="sf_email" name="email" type="email" required maxLength={200} ... />
...
<button type="button" onClick={() => setStep(2)}>Continue to confirmation</button>
</section>
{/* STEP 2: confirm and submit (the only submit button is here) */}
<section hidden={step !== 2}>
...
<button type="submit" disabled={pending}>Submit</button>
</section>
</form>
During a live production walkthrough by the author and Claude, we advanced to the step 2 confirmation screen without ever filling in the contact name or email, and pressing “Submit” produced no change on screen whatsoever — no error message, no loading state, not even a visible reaction to the click. The only trace was a single warning in the browser devtools console:
An invalid form control with name='name' is not focusable.
Cause
Moving between steps is implemented with a type="button" that only mutates React state.
<button type="button" onClick={() => setStep(2)}>
Continue to confirmation
</button>
Clicking a type="button" is not a form submission, so native browser input validation never runs here at all. The required name and email fields can be empty and the user still advances straight to the step 2 confirmation screen without any friction.
The only type="submit" button anywhere in the form is “Submit,” on step 2.
<button type="submit" disabled={pending}>
{pending ? "Submitting…" : "Submit"}
</button>
The moment a user presses it, the browser runs native HTML5 validation against the entire <form>. name and email are required, so an empty value is correctly flagged as invalid — but at this point step is 2, and the step 1 section containing those fields has hidden={step !== 1} evaluate to true, making it display:none. An element with display:none cannot receive focus. The browser tries to focus the offending field to show the validation error to the user, fails, logs a warning to the console, and silently cancels the submission. No visible error, no scroll, nothing.
In other words, what looked like “the user can just fill in the required field later” was actually a structure where the only way to reach that required field was already gone from the screen by the time submission was attempted.
Fix
noValidate was added to the form element, disabling native browser validation entirely.
{/* noValidate: the input gate is goConfirm (which shows errors) plus server-side
validation. Native validation silently blocks when a required field sits in a
hidden section, which looks like a hang, so it's disabled. */}
<form action={formAction} noValidate>
In its place, step 1’s “Continue to confirmation” button now calls goConfirm, which runs an explicit check.
const goConfirm = () => {
const missing: string[] = [];
if (creativeMode === "omakase") {
if (!omBiz.trim()) missing.push("business type");
if (!omMessage.trim()) missing.push("key message");
}
if (!company.trim()) missing.push("business/company name");
if (!contactName.trim()) missing.push("contact name");
if (!email.trim()) missing.push("email address");
else if (!EMAIL_RE.test(email.trim()))
missing.push("email address (check the format)");
if (missing.length > 0) {
setStepError(`Missing fields: ${missing.join(", ")}`);
return;
}
setStepError(null);
setStep(2);
};
If anything is missing, a role="alert" message renders right there — on step 1, which is still visible — and the user is not advanced to step 2.
{stepError && (
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{stepError}
</p>
)}
This check can also cover fields the HTML5 required attribute never saw, like the business type and key message that only apply when omakase mode is selected. Native validation only looks at fields marked required; goConfirm can express business rules (these two fields are mandatory only in omakase mode) directly in code. Final validation is still left to the server-side Server Action, unchanged.
Prevention
Packing multiple steps of input into a single <form> and toggling visibility with hidden is fundamentally at odds with native HTML5 validation. Browser input validation looks at the whole form and hunts for “the first invalid field that can receive focus” — it has no concept of which step is currently displayed. The moment required fields and the submit button end up on different steps, it becomes possible for the field validation needs to reach right when submission is attempted to have already vanished from the screen.
To stay safe with this pattern, pick one of two approaches up front: (1) treat any multi-step form as noValidate by default and write an explicit check on every step transition, or (2) split each step into its own separate <form> so native validation stays self-contained within a single step. A form that “looks like it works but silently can’t submit” is hard to catch with automated tests — this one only surfaced through a live, hands-on walkthrough in production.
Frequently asked questions
Q1Why did nothing happen when the submit button was pressed?
All three steps lived inside one form element, shown or hidden via the hidden attribute. The required field was on step 1; submit was on step 2. Native validation found the empty field but couldn't focus it, since its section was hidden, so it aborted submission with no visible error.
Q2Why wasn't the field validated when the "Continue to confirmation" button was pressed?
That button was implemented as type="button" — clicking it only advanced a React state variable to move to the next step. Browser input validation only runs when a type="submit" button is pressed or the form is otherwise submitted, so nothing was checked during the step transition itself.
Q3Did anything show up in devtools?
The console logged a single warning: An invalid form control with name='name' is not focusable. The browser had detected a validation error but decided it couldn't surface it to the user because the field couldn't receive focus, so nothing changed on screen.
Q4How was it fixed?
noValidate disables native validation entirely. The "Continue to confirmation" button now runs an explicit check and renders a role="alert" error message on the spot when required fields are empty. Final validation is still left to the server-side Server Action.
Environment verified
- Next.js 16.2.7 / React 19.2.4
- Found 2026-07-18 during a live production walkthrough by the author and Claude; fixed the same day
What this article is based on
- TypeScript file lines 65-100commit 21b33a5
- TypeScript file lines 296-345commit 21b33a5
- TypeScript file lines 394-403commit 21b33a5
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.