Form Validation That Respects Users: Native HTML, ARIA and Timing
Form validation that doesn’t nag: native HTML5 validation :user-invalid, ARIA wiring, blur vs input timing, async debounce and the anti-patterns to drop.
Open the console on a contact form and type a single character into the email field. If a red border and “Please enter a valid email address” appear instantly, someone shipped validation that fires on every keystroke. The user has typed “j”. Of course it’s invalid. They’re not finished.
That one decision, when to tell someone they’re wrong, matters more than every regex in your codebase. The validation logic is usually fine. The timing and the announcements are where it falls apart. Form validation isn’t a correctness problem, it’s a conversation problem, and most implementations interrupt.
Here’s the version we actually ship: including the parts browsers now give you for free in 2026, and the parts you still have to wire by hand.
- Use
:user-invalidinstead of:invalid. It’s been in Firefox since 88, Safari since 16.5 and Chrome since 119, and it only matches after the user has interacted with the field, which kills the “everything is red on page load” bug without any JavaScript. - Validate on
blurfor the first error, then oninputonly for fields that are already in an error state. Showing an error while someone is mid-typing is the single most common usability failure in form validation. - Wire errors with
aria-invalid="true"plusaria-describedbypointing at a persistent error container.aria-errormessagestill has patchy screen reader support, so it’s not the safe default yet. - Set
novalidateon the form but keep every constraint attribute. You get the Constraint Validation API and theValidityStateobject without the unstyleable, auto-dismissing native bubbles. - Never disable the submit button until a form is valid. It hides the reason, breaks keyboard flow, and gives screen reader users nothing to act on. Let them submit and give them an error summary with focus moved to it.
What native HTML5 validation already gives you
The Constraint Validation API has been in every browser for over a decade, and it’s still underused. Not the bubbles, which are genuinely bad. The API underneath.
Every form control exposes a validity object with the specific reason it failed: valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, stepMismatch, badInput and customError. That’s your error message routing table, already computed, already correct, with no regex of your own.
<form id="enquiry" novalidate>
<div class="field">
<label for="email">Work email</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
spellcheck="false"
aria-describedby="email-hint email-error">
<p id="email-hint" class="hint">We reply from a real address, not noreply@</p>
<!-- Container always exists in the DOM. Only the text changes. -->
<p id="email-error" class="error" aria-live="polite"></p>
</div>
</form>
Two things there are deliberate. novalidate stops the browser from showing its own tooltip but leaves checkValidity() and validity fully functional. The error paragraph exists before there’s an error because injecting both the element and its text in the same frame is how announcements get swallowed, particularly in Safari with VoiceOver.
One caveat on type="email": the HTML spec’s definition is a willful violation of RFC 5322, and it accepts jane@localhost with no dot in the domain. If you need a TLD, layer a pattern on top rather than replacing the type, because the type also gives you the correct mobile keyboard. Resist the urge to write a 200 character email regex. A confirmation email catches real problems better than any pattern ever will.
Input types worth getting right
- Phone numbers, postcodes, card numbers: use
type="text" inputmode="numeric", nottype="number". Number inputs strip leading zeros, respond to scroll wheel, and reject spaces that people genuinely type. - One-time codes:
autocomplete="one-time-code"gets iOS and Android to offer the SMS code above the keyboard. - Addresses: the full
autocompletetoken set (address-line1,postal-code,country-name) is a WCAG 1.3.5 requirement at AA, not an optimisation.

Timing: the part everyone gets wrong
There are four moments you can validate a field: on input, on blur, on submit, and never. The right answer is a state machine, not a choice.
Here’s the rule, refined across a lot of client builds:
- While the field has never errored: validate on
bluronly. Say nothing during typing. - Once a field is showing an error: validate on
input, so the error clears the instant it’s fixed. This is the reward loop, and it’s the one people skip. - On submit: validate everything, render all errors, move focus to a summary.
- Never validate a field the user hasn’t touched before submit.
That asymmetry is the whole trick. Pessimistic before the first error, optimistic after. Error appearance is interruptive, so it waits for a natural pause. Error removal is reassuring, so it happens immediately.
Two exceptions where validating during typing makes sense. Password strength meters, because the whole point is live feedback on progress, not failure. And character counters on a textarea with a hard server limit, announced with aria-live="polite" and only from about 80 percent of the limit onwards, so it isn’t chattering at every keystroke.
const form = document.getElementById('enquiry');
const touched = new WeakSet(); // fields that have been blurred
const errored = new WeakSet(); // fields currently showing an error
const MESSAGES = {
valueMissing: 'This field is required.',
typeMismatch: 'Enter a complete email address, like [email protected].',
patternMismatch:'Use the format shown in the hint.',
tooShort: (el) => Use at least ${el.minLength} characters.,
rangeOverflow: (el) => Choose a value no higher than ${el.max}.,
stepMismatch: 'Choose one of the allowed increments.',
badInput: 'That value could not be read. Check for stray characters.'
};
function messageFor(el) {
if (el.validity.customError) return el.validationMessage;
for (const key in MESSAGES) {
if (el.validity[key]) {
const m = MESSAGES[key];
return typeof m === 'function' ? m(el) : m;
}
}
return 'Please check this field.';
}
function setError(el, text) {
const box = document.getElementById(${el.id}-error);
if (!box) return;
if (text) {
el.setAttribute('aria-invalid', 'true');
box.textContent = text;
errored.add(el);
} else {
el.removeAttribute('aria-invalid'); // remove, don't set "false"
box.textContent = '';
errored.delete(el);
}
}
form.addEventListener('blur', (e) => {
const el = e.target;
if (!el.willValidate) return;
touched.add(el);
setError(el, el.checkValidity() ? '' : messageFor(el));
}, true); // capture phase: blur does not bubble
form.addEventListener('input', (e) => {
const el = e.target;
// Only re-validate live if we already told them something was wrong.
if (!el.willValidate || !errored.has(el)) return;
if (el.checkValidity()) setError(el, '');
});
Note the true on the blur listener. blur doesn’t bubble, so without capture-phase delegation you’d be attaching listeners per field, which breaks the moment you add a field dynamically. Also note that we remove aria-invalid rather than setting it to "false". Both are technically fine, but removal keeps your DOM inspector honest about which fields are actually in an error state.
Accessible form validation: the ARIA wiring that gets announced
Accessible form validation comes down to three connections. Getting any one of them wrong makes the other two pointless.
The label. A real <label for>, every time. Placeholder-as-label is still the most common accessibility defect we find in audits, and it fails the moment the user starts typing. We’ve covered the label and focus side of this in more depth in Form Accessibility: Labels, Errors and Focus Management.
The state. aria-invalid="true" on the control. This is what makes a screen reader say “invalid entry” when focus lands on the field.
The description. aria-describedby referencing both the hint and the error container. Space-separated IDs, in reading order. The hint stays referenced permanently; only the error text changes.
You’ll read that aria-errormessage is the semantically correct attribute, and it is. Support is still uneven enough across NVDA, JAWS and VoiceOver combinations that we don’t rely on it alone in production. If you want it, add it alongside aria-describedby and accept a possible double announcement in some readers. That’s the lesser evil compared to silence.
On live regions: use aria-live="polite" on the per-field error container, not role="alert". role="alert" is assertive and interrupts whatever the reader is mid-sentence on. Five of them firing on submit creates noise, not information. Save assertive announcements for the submit-level summary, or skip them entirely if you’re moving focus anyway.
Submit: the error summary and where focus goes
On submit failure, field-by-field errors aren’t enough. Someone using a screen reader or 400 percent zoom has no overview. Build a summary.
form.addEventListener('submit', (e) => {
const fields = [...form.elements].filter(el => el.willValidate);
const invalid = fields.filter(el => !el.checkValidity());
fields.forEach(el => setError(el, el.checkValidity() ? '' : messageFor(el)));
if (!invalid.length) return; // let it through
e.preventDefault();
const summary = document.getElementById('form-summary');
summary.innerHTML = `
<h2 tabindex="-1" id="summary-heading">There are ${invalid.length}
problem${invalid.length > 1 ? 's' : ''} with this form</h2>
<ul>${invalid.map(el => {
const label = form.querySelector(label[for="${el.id}"]);
return <li><a href="#${el.id}">${label.textContent}: ${messageFor(el)}</a></li>;
}).join('')}</ul>`;
summary.hidden = false;
// Move focus to the heading, not the first field: context before correction.
document.getElementById('summary-heading').focus();
});
Focus goes to the summary heading, not the first invalid input. That ordering matters. Jumping straight to the field tells a non-sighted user that something failed somewhere, with no sense of scale. The summary tells them there are three problems and lets them navigate to each by link. Sighted keyboard users get the same benefit.
The links use plain fragment hrefs, so they work before your JavaScript hydrates and they hand off focus and scroll handling to the browser.
Styling validity without lying to the user
:invalid is a trap. It matches from first paint, which means an empty required field is red before anyone has done anything. People then patch around it with .was-submitted :invalid class hacks. You don’t need to anymore.
/ Only after the user has interacted and left the field in a bad state /
.field input:user-invalid {
border-color: var(--color-danger);
background-image: url("data:image/svg+xml,...") / warning glyph /;
}
.field input:user-valid {
border-color: var(--color-success);
}
.field .error {
color: var(--color-danger-text); / 4.5:1 against the page background /
font-size: 0.875rem;
min-height: 1.25rem; / reserve space: no layout shift when text appears /
}
@media (prefers-reduced-motion: no-preference) {
.field .error { transition: opacity 120ms ease-out; }
}
Two details that get skipped. The min-height reserves vertical space so the form doesn’t jump when an error appears. Without it you get a CLS problem and, worse, the button the user was about to click moves under their cursor. Never signal validity by colour alone either: WCAG 1.4.1 means you need an icon, a border weight change or text alongside the red. Keep colours in CSS variables and this is a two-line change, which is one of the arguments for a token-based CSS variables system.
Async validation, debounce and abort
Checking whether a username or a VAT number is taken means a network round trip. The naive implementation fires one request per keystroke, then renders whichever response lands last. Which is not necessarily the latest one.
let controller;
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
const checkSlug = debounce(async (el) => {
controller?.abort(); // cancel the in-flight request
controller = new AbortController();
el.setAttribute('aria-busy', 'true');
try {
const res = await fetch(/api/slug?v=${encodeURIComponent(el.value)},
{ signal: controller.signal });
const { available } = await res.json();
// setCustomValidity keeps the field inside the native validity model
el.setCustomValidity(available ? '' : 'That handle is already taken.');
setError(el, available ? '' : el.validationMessage);
} catch (err) {
if (err.name !== 'AbortError') el.setCustomValidity(''); // fail open
} finally {
el.removeAttribute('aria-busy');
}
}, 450);
450ms is where we’ve landed. Below roughly 300ms you’re firing requests on normal typing cadence. Above 600ms the feedback feels broken. Note the fail-open in the catch: if your availability endpoint is down, don’t block the submission. Server-side validation is the real gate and will reject the duplicate properly. Client-side checks are a courtesy, so treat their failure as a non-event.
setCustomValidity is the right tool here because it keeps async results inside the same ValidityState model as everything else. Call it with an empty string to clear. Otherwise the field stays invalid forever and your submit handler quietly blocks the form. We’ve debugged that exact bug on a client site at 11pm. It’s not obvious from the outside.
Four things that feel helpful and aren’t
Disabling the submit button until valid. This is usually the wrong choice. It removes the user’s ability to ask “what’s wrong?”, gives screen readers a dead control with no explanation, and on long forms leaves people hunting for the field they missed. Let them submit, then tell them precisely what to fix, with focus moved to the summary. The only case where disabling is defensible is preventing double submission after a successful click.
Hard maxlength on free text. It silently truncates pasted content. Someone pastes a 600 character message into a 500 character field and loses the end without being told. Use a higher maxlength than your real limit, or none, plus a counter and a tooLong error.
Blocking paste on password and email confirm fields. Still happens. It breaks password managers and makes people choose weaker, typeable passwords.
Trimming whitespace only on the server. Trim on the client before validating, otherwise a trailing space from autofill produces a typeMismatch the user literally cannot see.
What we check before handover
The checklist is short and every item has caught a real bug:
- Tab through the whole form with no mouse. Every error reachable, focus visible at every stop.
- Submit the empty form. Count the announcements. If it’s more than one summary plus per-field descriptions on focus, you have assertive regions you shouldn’t.
- Zoom to 400 percent at 1280px width. Errors still associated with the right field, nothing clipped.
- Turn JavaScript off. The form must still submit and still be validated and re-rendered by the server. If your form posts to an third-party endpoint such as WebForms, confirm its server-side rules match your client-side ones, otherwise you’ll accept input the client rejected or vice versa.
- VoiceOver on Safari plus NVDA on Firefox, minimum. These two disagree about live regions more often than any other pairing.
- Check the drop-off data a fortnight after launch. Validation that looks fine in review can still be the field where 40 percent of people quit, and the only way to know is to measure it. We cover the instrumentation in Form Analytics: Measuring Drop-off Field by Field.
That last point deserves emphasis. Validation is a hypothesis about what users will get wrong. Analytics tells you whether you were right.
Frequently Asked Questions
Should I use a validation library or write it myself?
For a contact or enquiry form, write it yourself: the code above is roughly 80 lines and you control the timing exactly. Reach for a library (Zod or Valibot paired with a thin DOM layer) when you have a multi-step form, conditional field logic, or you want to share the same schema between client and server. Avoid libraries that replace native inputs with custom widgets, because you then inherit their accessibility bugs instead of the browser’s well-tested behaviour.
Is :user-invalid safe to use in 2026 without a fallback?
Yes for progressive enhancement. It’s been in Firefox since 88, Safari since 16.5 and Chromium since 119, so the only browsers missing it are ones that won’t render your layout correctly anyway. If a user is on something older they simply don’t get the red border, while the JavaScript-driven error text and ARIA attributes still work. That’s an acceptable degradation, unlike relying on :invalid and showing everyone errors on page load.
Can I rely on HTML5 validation alone and skip JavaScript?
You can, and for a simple newsletter signup it’s a defensible call. The costs are real though: native bubbles cannot be styled, auto-dismiss after a few seconds, show only one error at a time, and are localised to the browser’s UI language rather than your page language, which breaks badly on multilingual sites. Keep the constraint attributes either way, because they’re also your server-independent documentation of the rules.
Where should the error message sit, above or below the field?
Below the input and above the next label. That’s where both Nielsen Norman and the GOV.UK Design System land, though GOV.UK places it between the label and the input. Either works as long as it’s inside the same field container and referenced by aria-describedby. What doesn’t work is a tooltip or an error in a far-right column: at narrow widths or high zoom it decouples from the field entirely.
Does validation help with spam?
Barely. Bots submit well-formed data because they’re built against your field names, not your UI, so a strict email regex filters almost nothing. Timing checks (a form completed in under two seconds), honeypot fields and server-side scoring do far more, and we go through what still works in Contact Form Spam: What Works in 2026 Beyond CAPTCHA.
If you only change one thing after reading this, change the timing. Pull your error display off input and onto blur, then re-validate on input only for fields already in error. It’s maybe fifteen lines of diff, it needs no new dependency, and it converts a form that nags into one that waits its turn. Do the ARIA wiring next, and the error summary after that.


