Form Validation UX: When to Validate, and Error Messages That Actually Help
When to validate a form field (not while typing, on blur, on submit, and again on the server), how to write error messages people can act on, and the HTML and ARIA that makes them work for everyone. With a complete example.
The ShipMyForm team
· 7 min read
Validate a field when the person has finished with it, not while they are typing it; show the error next to the field, say what to do, keep what they typed; on submit, check everything and focus the first problem; then validate again on the server, because the browser is advisory. That is the whole discipline. This guide is the implementation: the timing rules, a formula for the copy, the HTML and ARIA that make it work with a screen reader, and one complete example that handles client and server errors with the same code.
It goes deeper on two sections of our form UX guide, which covers the wider decisions (field count, labels, autocomplete, keyboards). Full disclosure: ShipMyForm is our product; it appears once, at the server-side step, because that step is the one people skip.
The three moments, and what to do at each
| Moment | Do | Why |
|---|---|---|
| Typing in a field for the first time | Nothing | Nobody has finished yet. Flagging "invalid email" at jo is the single most hostile thing a form does |
| Leaving a field they typed in (blur) | Validate that field | This is the moment they consider it done. An untouched, skipped field stays quiet |
| Typing in a field that already shows an error | Re-validate on every keystroke | The error should disappear the instant it is fixed, not when they leave again |
| Pressing submit | Validate everything, list every error, move focus to the first invalid field | One round trip, not one error per attempt |
| On the server | Validate again, return per-field messages | The browser can be bypassed with one curl. Server rules are the real ones |
Two consequences that people miss. First, "reward early, punish late": if a field is already valid on blur, you may show a quiet confirmation (a tick, a green border plus text); do not withhold that and only ever show red. Second, the submit button stays enabled. Disabling it until the form is valid hides the errors the person needs to see and gives them nothing to click to find out what is wrong.
Let HTML do the first pass
Constraint validation is built into the platform and costs nothing:
<input name="email" type="email" required autocomplete="email">
<textarea name="message" required minlength="10" maxlength="2000"></textarea>
<input name="phone" type="tel" inputmode="tel" pattern="[+0-9 ()-]{7,}">Two properties make it usable. :user-invalid (all modern browsers since
2023) matches only after the person has interacted with the field, so you can
style errors without the page loading already covered in red, which is what
:invalid does:
input:user-invalid,
textarea:user-invalid {
border-color: #dc2626;
}And novalidate on the <form> keeps the checks while switching off the
browser's bubbles, which differ per browser, show one error at a time, and
vanish on the next click. You then read the same results through
element.validity and element.validationMessage and render them yourself,
next to the field. That is what the example below does.
Write the message so the next action is obvious
An error message has one job: make the fix obvious. The formula is what is wrong, then what to do, then an example if one helps, in plain words, about the value rather than the person.
| Instead of | Say |
|---|---|
| "Invalid email" | "Enter an email address, like [email protected]" |
| "Required" | "Enter your name" |
| "Must be 10–2000 characters" | "Tell us a bit more. Messages need at least 10 characters" |
| "Phone number format incorrect" | "Enter a phone number with the country code, like +44 20 7946 0000" |
| "File too large" | "Choose a file under 10 MB. This one is 14 MB" |
| "Something went wrong" | "We couldn't send your message. Check your connection and try again; nothing was lost" |
Rules of thumb that fall out of the examples:
- Lead with the fix when the fault is obvious. "Enter your name" beats "Name is required"; the person already knows it is required, that is why the box is red.
- Give the number when there is one. "You have 8 of 12 characters" is something to act on; "does not meet requirements" is not.
- No blame, no exclamation marks, no jargon. "Invalid input", "malformed", "illegal characters" describe your parser, not their mistake.
- Do not lecture. One short sentence. If a rule genuinely needs explaining (why the phone number needs a country code), put the explanation in the hint text above the field, permanently, not in the error.
- Keep what they typed. Clearing a field, or the form, on error is how you lose people. Show the error next to the value that caused it.
Wire it up so it works for everyone
Position and colour are the visible half. The other half is the markup that lets assistive technology find the message and read it at the right time.
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email"
aria-describedby="email-hint email-error">
<p id="email-hint" class="hint">We reply to this address.</p>
<p id="email-error" class="error" hidden></p>
</div>aria-describedbypoints at the hint and the error (the error element exists even while empty and hidden, so the relationship is stable). When the field receives focus, a screen reader announces the label, then the hint, then the error.aria-invalid="true"goes on the input when it fails, and comes off when it passes. Screen readers announce "invalid" with the field.- Never rely on colour alone. Red text plus an icon or a prefix ("Error:") plus the wording change. Around one in twelve men cannot rely on red versus green.
- On submit, move focus to the first invalid field. The person is looking at the button, three screens below the error. Focus brings both them and the screen reader to it.
- Summarise multiple errors in a live region if the form is long: a short
"3 fields need attention" in an
aria-live="polite"element above the form, with links to each field. For a three-field contact form, focusing the first error is enough.
A complete example
One renderer handles both browser constraint errors and errors that come back from the server, so the two never look different. It uses the timing table above: quiet on first typing, validate on blur, live re-validate after an error, everything on submit.
<form id="contact" action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST" novalidate>
<div class="field">
<label for="name">Name</label>
<input id="name" name="name" required autocomplete="name" aria-describedby="name-error">
<p id="name-error" class="error" hidden></p>
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email"
aria-describedby="email-hint email-error">
<p id="email-hint" class="hint">We reply to this address.</p>
<p id="email-error" class="error" hidden></p>
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" required minlength="10" rows="5"
aria-describedby="message-error"></textarea>
<p id="message-error" class="error" hidden></p>
</div>
<p id="form-status" aria-live="polite"></p>
<button type="submit">Send message</button>
</form>const form = document.querySelector("#contact");
const status = document.querySelector("#form-status");
// Friendlier copy than the browser's defaults, keyed by field and failure.
const MESSAGES = {
name: { valueMissing: "Enter your name" },
email: { valueMissing: "Enter your email address",
typeMismatch: "Enter an email address, like [email protected]" },
message: { valueMissing: "Enter a message",
tooShort: (el) => `Tell us a bit more. Messages need at least 10 characters, you have ${el.value.length}` },
};
function messageFor(el) {
const v = el.validity;
const table = MESSAGES[el.name] || {};
for (const key of ["valueMissing", "typeMismatch", "tooShort", "tooLong", "patternMismatch"]) {
if (v[key]) {
const m = table[key];
return typeof m === "function" ? m(el) : (m || el.validationMessage);
}
}
return "";
}
function setError(el, text) {
const out = document.getElementById(`${el.name}-error`);
out.textContent = text ? `Error: ${text}` : "";
out.hidden = !text;
el.setAttribute("aria-invalid", text ? "true" : "false");
el.classList.toggle("is-invalid", Boolean(text));
}
function validate(el) {
const text = el.checkValidity() ? "" : messageFor(el);
setError(el, text);
return !text;
}
for (const el of form.querySelectorAll("input, textarea")) {
// Quiet until they leave the field having typed something.
el.addEventListener("blur", () => { if (el.value !== "") validate(el); });
// Once an error is showing, clear it the moment it is fixed.
el.addEventListener("input", () => { if (el.getAttribute("aria-invalid") === "true") validate(el); });
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fields = [...form.querySelectorAll("input, textarea")];
const invalid = fields.filter((el) => !validate(el));
if (invalid.length) {
status.textContent = invalid.length === 1 ? "1 field needs attention." : `${invalid.length} fields need attention.`;
invalid[0].focus();
return;
}
status.textContent = "Sending…";
const button = form.querySelector("button");
button.disabled = true; // only now, to stop double submits
try {
const res = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const body = await res.json();
if (res.status === 422 && body.error === "validation") {
// Server rules failed: same renderer, same placement.
for (const [name, text] of Object.entries(body.fields)) {
const el = form.elements[name];
if (el) setError(el, text);
}
status.textContent = "Some fields need attention.";
form.elements[Object.keys(body.fields)[0]]?.focus();
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
status.textContent = "Sent. We'll reply within a day.";
form.reset(); // only after success
} catch {
status.textContent = "We couldn't send your message. Check your connection and try again; nothing was lost.";
} finally {
button.disabled = false;
}
});Things to notice, because they are the parts people cut when they are in a hurry:
novalidatepluscheckValidity()keeps the browser's checks and replaces its presentation.- The
MESSAGEStable is where the copy lives. The fallback is the browser's own message, so an unhandled case is never blank. - The button is disabled only while a request is in flight, never as a "form is not valid yet" state.
- The 422 branch is the server saying no. Here it is ShipMyForm's
validation rules returning per-field
messages; the shape (
fields: { name: message }) is the one to aim for in any backend, because it renders with the same three lines as client errors. - Network failure gets a real sentence and keeps the values. "Something went wrong" with a cleared form is the worst outcome a form can produce.
Everything up to the submit handler still works without a script: the constraint attributes stop an empty or malformed submission in the browser. What you lose is control over the bubbles and inline server errors. If the endpoint rejects a no-JS post, ShipMyForm sends the visitor to a hosted error page that asks them to go back and fix the entry, with their values intact.
Success is a state too
The last message in a form is the one most people forget to design. After a
successful send: say it happened, say what happens next, and say it where
they are looking. "Sent. We'll reply within a day." in the live region, next
to the button, beats a full-page redirect for a three-field form, and a
redirect to a real thank-you page beats both when there is something to do
next (a calendar link, a download). Do not alert(), do not clear the page
to a spinner, and do not leave the button reading "Sending…" forever when the
request fails.
Checklist
- Nothing is validated while someone types in a field for the first time.
- A field is validated on blur, once it has a value.
- After an error shows, it clears live as soon as the field becomes valid.
- Submit validates everything, announces the count, and focuses the first invalid field.
- Every error sits below its field, is linked with
aria-describedby, and setsaria-invalid. - Every message says what to do, with a number or an example where that helps, and never uses colour alone.
- Values survive every error, including server and network errors.
- The submit button is never disabled for "not valid yet", only while a request is in flight.
- The server validates again and returns per-field messages.
- Success is a sentence, not a silence.
Next steps
- The rest of the decisions that move completion rates: form UX best practices
- Server-side rules and the 422 shape used above: add validation to your form
- Markup to start from, honeypot included: HTML contact form templates
- Long forms that need splitting: multi-step forms in HTML
Frequently asked questions
- Should I validate on every keystroke?
- Not the first time through a field. Validating while someone types means calling their email invalid at the second letter. Validate on blur once they have typed something, then re-validate live only after an error is showing so it clears the moment they fix it.
- Is the browser's built-in validation enough?
- It is the right first pass and it is free: required, type=email, minlength, pattern. Its bubbles are inconsistent across browsers and vanish on the next click, though, so most forms add novalidate and render the same constraint errors themselves, next to the field, with aria-describedby.
- Where should the error message go?
- Directly below the field it belongs to, linked with aria-describedby and aria-invalid on the input. On submit, also focus the first invalid field. A single red banner at the top of the form is the pattern with the worst completion numbers.
- What makes an error message good?
- It says what is wrong and what to do next, in that order, with an example where one helps: "Enter an email address, like [email protected]". No blame, no jargon, no "invalid input", and never colour alone to signal it.
- Do I still need server-side validation if the client validates?
- Yes. Anything enforced only in the browser can be bypassed with one request. Client-side validation is a user-experience feature; the server is the rule. ShipMyForm runs validation rules on the endpoint and returns a 422 with per-field messages you can render with the same code as your client errors.
Related guides
Form UX: The Decisions That Actually Change Completion Rates
Most form UX advice is decoration. These are the decisions that measurably change whether people finish: field count, labels, validation timing, error copy, and letting the browser help.
How to Add Validation to Your Form
Reject bad submissions with server-side rules — required fields, formats, and limits — in a few clicks, no backend code. fetch() callers get per-field errors.
How to Build a Multi-Step Form in HTML (No Library Needed)
One form, several steps, about 30 lines of JavaScript. Includes the whole-form validation trap that silently breaks the Enter key, and a generator that writes it all for you.