The browser can do most of it
Constraint validation is built in. You describe the rule in an attribute; the browser blocks the submission, focuses the first bad field and announces the problem in the user's own language. No JavaScript, no library.
| Attribute | Rule |
|---|---|
required | Must not be empty. |
minlength / maxlength | Length of text. maxlength stops typing; minlength only blocks submission. |
min / max | Range for numbers, dates and times. |
step | Allowed increments. step="0.01" for money, step="any" to switch it off. |
pattern | A regular expression the whole value must match. |
type | email and url validate their own format. |
pattern needs a title
pattern is anchored at both ends automatically — you do not write ^ or $. Always pair it with title describing the format, because the browser puts that text in the error message.
<label for="pin">PIN</label>
<input type="text" id="pin" name="pin"
inputmode="numeric"
pattern="[0-9]{4}"
title="Four digits"
required>
Valid email addresses are far stranger than the regular expressions people write for them. Use type="email" and, if it matters, send a confirmation message — that is the only real test.
Styling the states
CSS can see validity. The trick is to wait until the reader has had a chance: :user-invalid only matches after they have interacted with the field, so a blank form is not covered in red before anyone types.
:requiredand:optional— what the field is.:valid/:invalid— match immediately, including on an untouched empty field.:user-valid/:user-invalid— match only after interaction. Prefer these.:in-range/:out-of-range— for numbers with min and max.:placeholder-shown— useful for floating-label effects.
input:user-invalid {
border-color: #c2452d;
}
input:user-valid {
border-color: #3f8f5e;
}
/* the whole form, only once submission has been attempted */
form.submitted input:invalid {
border-color: #c2452d;
}
The API
const email = document.querySelector("#email");
email.validity.valid; // true or false
email.validity.valueMissing; // required, but empty
email.validity.typeMismatch; // not an email address
email.validity.patternMismatch;
email.validity.tooShort;
email.validity.rangeOverflow;
email.validationMessage; // the browser's own wording
form.checkValidity(); // true/false, fires invalid events
form.reportValidity(); // the same, and shows the bubbles
Your own message
setCustomValidity replaces the message for a field. A non-empty string makes the field invalid; an empty string clears it again. Clearing it is the step everybody forgets, and the field then stays invalid forever.
const pass = document.querySelector("#password");
const again = document.querySelector("#confirm");
function compare() {
again.setCustomValidity(again.value === pass.value ? "" : "The two passwords do not match.");
}
pass.addEventListener("input", compare);
again.addEventListener("input", compare);
Replacing the bubbles
The native bubbles cannot be styled. To use your own messages, stop the default with novalidate on the form — the API keeps working, only the browser's UI is suppressed — and write the message into an element the field points at.
<form novalidate>
<label for="email">Email</label>
<input type="email" id="email" name="email" required
aria-describedby="email-error">
<p id="email-error" class="error" role="alert"></p>
<button>Send</button>
</form>
const form = document.querySelector("form");
const email = form.email;
const out = document.getElementById("email-error");
form.addEventListener("submit", (e) => {
out.textContent = "";
if (!email.checkValidity()) {
e.preventDefault();
out.textContent = email.validity.valueMissing
? "Please enter your email address."
: "That does not look like an email address.";
email.focus();
}
});
Turning it off, on purpose
novalidateon the form — suppress the browser UI, keep the API.formnovalidateon a button — this one button submits without validating. Exactly right for "Save draft".
The rule that matters most
Client-side validation is a convenience. It saves a round trip and gives instant feedback. It is not a defence: anyone can send a request without your page, using the browser console, curl or a script.
Every rule you enforce in the browser must be enforced again on the server. Treat the browser's version as a nicety and the server's version as the truth.
Over-strict rules reject real people: names with apostrophes, addresses abroad, phone numbers with a country code. Ask what actually breaks if the value is odd, and only reject that.