Disabling the Submit Button Until the Form Is Valid
This recipe shows how to keep a submit button disabled until every native constraint passes, driving the button’s state from the Constraint Validation API so the gate stays in lockstep with required, type, and pattern rules rather than a hand-rolled boolean you have to keep in sync.
When to Use This form.checkValidity() Approach
Gating the button on form.checkValidity() is the right call when the form is short, self-contained, and validated entirely by declarative HTML constraints. It is the wrong call when validity depends on work the button state cannot see synchronously.
- Reach for it when the form’s rules are fully expressed by attributes (
required,type="email",minlength,pattern) and you want an at-a-glance affordance that the form is complete. - Prefer a submit-time gate — see Prevent Default Submission Without Losing Validation — when validity depends on asynchronous server checks, because a disabled button cannot represent “pending”.
- Avoid it as your only guard when rules span multiple inputs; route cross-field validation through
setCustomValidityfirst socheckValidity()reflects the real state.
Gating the Button With form.checkValidity() and the input Event
Keep the house baseline: ship <form novalidate> so the browser suppresses its blocking popups, then drive both the button state and the eventual submit through the Constraint Validation API. The input event fires on every keystroke and covers programmatic value changes bubbling from any field, so a single delegated listener keeps the button in sync.
const form = document.querySelector<HTMLFormElement>("#signup")!;
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
// Single source of truth: the native validity of the whole form.
// checkValidity() runs every constraint without showing UI, which is
// exactly what a disabled-state gate needs.
function syncSubmitState(): void {
submit.disabled = !form.checkValidity();
}
// The input event bubbles from every field and fires on each edit,
// including paste and autofill, so one delegated listener suffices.
form.addEventListener("input", syncSubmitState);
// Run once on load so a pre-filled or restored form starts correct.
syncSubmitState();
// Even with the gate, validate at submit — the button is a hint,
// never the security boundary. novalidate keeps popups off; we
// call reportValidity() ourselves to render messaging and focus.
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
form.submit(); // or dispatch fetch / router navigation here
});
<form id="signup" novalidate>
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email" />
<label for="pw">Password</label>
<input id="pw" name="pw" type="password" required minlength="8" />
<button type="submit" disabled>Create account</button>
</form>
Note the button ships with the disabled attribute already set in markup, so the control is inert before the first syncSubmitState() call resolves — no flash of an enabled button on a blank form.
Configuration: Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
novalidate (attribute) |
boolean attr | absent | Suppresses native popups while keeping checkValidity() / reportValidity() live. |
| Gate method | checkValidity | reportValidity |
checkValidity |
Use checkValidity() for the silent state sync; reserve reportValidity() for the submit gate. |
| Sync event | "input" | "change" |
"input" |
input updates per keystroke; change waits for blur/commit and feels laggier. |
disabled (button attr) |
boolean prop | true in markup |
The visible gate; start true to avoid an enabled flash. |
aria-disabled |
string | not set | Alternative to disabled when the button must stay focusable (see FAQ). |
Initial syncSubmitState() call |
function call | required | Corrects the button for autofilled or restored forms on load. |
Verification Steps
import { test, expect } from "@playwright/test";
test("submit stays disabled until every constraint passes", async ({ page }) => {
await page.goto("/signup");
const submit = page.getByRole("button", { name: "Create account" });
await expect(submit).toBeDisabled();
await page.fill("#email", "dev@example.com");
await expect(submit).toBeDisabled(); // password still empty
await page.fill("#pw", "hunter2!");
await expect(submit).toBeEnabled();
});
Edge Cases & Failure Modes
The button never enables because a constraint is invisible. A pattern typo or a stray setCustomValidity("...") that is never cleared keeps form.checkValidity() returning false forever, so the gate looks broken. Audit with per-field validity — see Reading ValidityState Flags — and remember to reset custom errors with setCustomValidity("") once the value becomes valid.
Keyboard and screen-reader users lose access to the errors. A truly disabled button is removed from the tab order and emits no click, so a user who cannot tell why the form is incomplete has no way to trigger reportValidity(). If discoverability matters, keep the button enabled and gate at submit instead, pairing it with accessible error messaging so the reason is always announced.
Async validity can never satisfy a synchronous gate. checkValidity() is synchronous and cannot await a uniqueness check or Zod schema validation that resolves later. Do not disable the button on pending async state; validate at submit and show a loading state during the round trip instead.
Keeping the Button Focusable With aria-disabled
The accessibility trade-off in the sections above is real: a native disabled button is yanked from the tab order and swallows its own click, so a keyboard or screen-reader user who has landed on it cannot ask why it is inert. The aria-disabled pattern keeps the control focusable and announced, while you intercept the click yourself and route it to reportValidity(). The button now works as a discoverable “tell me what’s missing” affordance rather than a dead end.
const form = document.querySelector<HTMLFormElement>("#signup")!;
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
// The button stays enabled and focusable; aria-disabled carries the
// semantic state to assistive tech and drives your disabled styling.
function syncSubmitState(): void {
const valid = form.checkValidity();
submit.setAttribute("aria-disabled", String(!valid));
}
form.addEventListener("input", syncSubmitState);
syncSubmitState();
// Because the button is never truly disabled, its click always fires.
// When the form is incomplete we cancel the submit and surface the
// reason instead of silently doing nothing.
submit.addEventListener("click", (event) => {
if (submit.getAttribute("aria-disabled") === "true") {
event.preventDefault();
form.reportValidity(); // announces + focuses the first invalid field
}
});
Style the aria-disabled state explicitly — button[aria-disabled="true"] { opacity: .5; cursor: not-allowed; } — because, unlike the native attribute, ARIA state carries no default presentation. This variant costs one extra click handler but removes the “why can’t I submit?” dead end entirely, which is usually the better default for anything longer than a two-field login.
Debouncing the Sync on Large or Expensive Forms
form.checkValidity() walks every submittable control in the form and evaluates each one’s constraints, firing an invalid event at any that fail (the reason checkValidity and reportValidity differ only in whether that event’s default is prevented). For a handful of inputs this is microscopically cheap and running it on every keystroke is correct. On a form with dozens of fields, complex pattern regexes, or setCustomValidity handlers that do real work, per-keystroke re-validation of the entire form can become noticeable during fast typing.
When profiling shows the sync is hot, debounce it to the trailing edge of a burst so the button settles once the user pauses, rather than recomputing on each character:
function debounce<A extends unknown[]>(
fn: (...args: A) => void,
ms: number,
): (...args: A) => void {
let handle: ReturnType<typeof setTimeout> | undefined;
return (...args: A) => {
clearTimeout(handle);
handle = setTimeout(() => fn(...args), ms);
};
}
// A 120ms trailing debounce is imperceptible to the user but collapses
// a burst of keystrokes into a single checkValidity() pass.
const syncSubmitState = debounce((): void => {
submit.disabled = !form.checkValidity();
}, 120);
form.addEventListener("input", syncSubmitState);
The trade-off is a sub-frame lag between the final keystroke and the button flipping enabled — invisible in practice at 120ms, but worth measuring rather than assuming. Reach for the debounce only when a profile justifies it; for the common short form, the un-debounced listener in the first example is simpler and already fast enough.
Frequently Asked Questions
Is disabling the submit button accessible?
Partially. A disabled button is removed from the tab order and gives no feedback about why it is inert, which frustrates screen-reader users. If you need the reason to be discoverable, keep the button enabled, use aria-disabled="true" for styling, and gate on reportValidity() at submit so errors are announced.
Should I use the input or change event to sync the button?
Use input. It fires on every keystroke, paste, and most autofill events, so the button tracks validity as the user types. The change event only fires on blur or commit, which makes the gate feel a step behind. Both bubble, so one delegated listener on the <form> covers all fields.
Why validate again at submit if the button is already disabled?
The disabled state is a UX hint, not a security boundary. The attribute can be removed in DevTools, and Enter-key submission or scripted dispatch can bypass a stale gate. Always call reportValidity() in the submit handler so an invalid form never reaches the network, and to render custom validity messages plus focus management after a failed submit.
Why does the button start enabled after the browser back button?
When a page is restored from the back/forward cache, the browser reinstates the DOM — including the field values — but does not re-run your module's top-level code, so your initial syncSubmitState() call never fires and the button keeps whatever state it had at unload. Listen for pageshow and re-sync when event.persisted is true: window.addEventListener("pageshow", (e) => { if (e.persisted) syncSubmitState(); }). This also covers restored forms whose values are now valid even though the button was disabled when the user left.
Related Guides
- checkValidity vs reportValidity — why the silent method drives the gate and the loud one drives submit.
- Prevent Default Submission Without Losing Validation — the submit-time gate to prefer when async checks are involved.
- Showing an Accessible Loading State — what to render once a valid form is dispatched.
- How to Use setCustomValidity Correctly — keep custom errors from silently pinning the button disabled.
- HTML5 Input Types & Attributes — the declarative constraints the gate reads.