HTML Form Validation Without a Library: The Constraint Validation API
Validate a sign-up form with built-in HTML attributes and the Constraint Validation API. Friendly error messages, accessible markup, full source and live demo.

The browser already knows whether an email address looks like one, whether a field is empty, and whether a password is long enough. It tracks all of that for every input, and it exposes the result to JavaScript. Most form libraries are, at heart, a nicer wrapper around data you already have.
This tutorial uses that data directly. You will write the rules in HTML attributes, read the results with the Constraint Validation API, and replace the browser's default error bubbles with messages that say something useful.
What you will build
A four-field sign-up form: email, username, password and a repeat-password field. Errors appear when you leave a field, disappear as soon as the value becomes valid, and the first invalid field gets focus on submit.
The frame is small, so open the demo in its own tab to try it at full size.
Step 1: let HTML do the easy part
<!-- In the <head>: <link rel="stylesheet" href="/style.css"> -->
<main class="page">
<h1>Create an account</h1>
<form id="signup" novalidate>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email" aria-describedby="email-error">
<p class="error" id="email-error"></p>
</div>
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" required minlength="3" maxlength="20" pattern="[a-z0-9_]+" autocomplete="username" aria-describedby="username-hint username-error">
<p class="hint" id="username-hint">Lowercase letters, numbers and underscores.</p>
<p class="error" id="username-error"></p>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password" aria-describedby="password-error">
<p class="error" id="password-error"></p>
</div>
<div class="field">
<label for="confirm">Repeat password</label>
<input id="confirm" name="confirm" type="password" required autocomplete="new-password" aria-describedby="confirm-error">
<p class="error" id="confirm-error"></p>
</div>
<button class="submit" type="submit">Create account</button>
<p class="success" id="success" role="status"></p>
</form>
</main>
<script src="/script.js" defer></script>
required, type="email", minlength and pattern are the rules. Nothing in the JavaScript repeats them, which keeps the markup as the single place you edit a rule.
The form has novalidate. That does not turn validation off. It only stops the browser from showing its own tooltip and blocking the submit, so your code can present the errors instead. The validity state is still computed for every field.
Each input is linked to its message through aria-describedby, so a screen reader reads the error together with the field. The empty p.error elements are hidden by :empty until there is text.
Step 2: read the validity state
const form = document.querySelector("#signup");
const password = form.elements.password;
const confirm = form.elements.confirm;
const fields = [...form.elements].filter((el) => el.matches("input"));
// One friendly sentence per validity state, per field.
const messages = {
email: {
valueMissing: "Enter your email address.",
typeMismatch: "That does not look like an email address.",
},
username: {
valueMissing: "Pick a username.",
tooShort: "Use at least 3 characters.",
patternMismatch: "Use lowercase letters, numbers and underscores only.",
},
password: {
valueMissing: "Choose a password.",
tooShort: "Use at least 8 characters.",
},
confirm: {
valueMissing: "Type the password again.",
customError: "The passwords do not match.",
},
};
function checkMatch() {
const mismatch = confirm.value !== "" && confirm.value !== password.value;
confirm.setCustomValidity(mismatch ? "Passwords do not match" : "");
}
function showError(field) {
const error = document.querySelector(`#${field.id}-error`);
let text = "";
if (!field.validity.valid) {
const rules = messages[field.name] || {};
const state = Object.keys(rules).find((key) => field.validity[key]);
text = rules[state] || field.validationMessage;
}
error.textContent = text;
field.setAttribute("aria-invalid", String(text !== ""));
}
// Validate a field when the visitor leaves it, not while they are still typing.
form.addEventListener("focusout", (event) => {
if (!event.target.matches("input")) return;
checkMatch();
showError(event.target);
});
// Once a field is marked wrong, clear the message as soon as it becomes valid.
form.addEventListener("input", (event) => {
checkMatch();
if (event.target.getAttribute("aria-invalid") === "true") showError(event.target);
});
form.addEventListener("submit", (event) => {
event.preventDefault();
checkMatch();
fields.forEach(showError);
const firstInvalid = fields.find((field) => !field.validity.valid);
if (firstInvalid) {
firstInvalid.focus();
return;
}
document.querySelector("#success").textContent =
"Looks good. In a real app you would send the form now.";
});
Every field has a validity object with named flags: valueMissing, typeMismatch, tooShort, patternMismatch and customError, among others. The messages table maps each flag to a human sentence, and showError picks the first flag that is true. If you forget a case, it falls back to the browser's own validationMessage, so nothing silently shows a blank error.
The one rule HTML cannot express
"The two passwords must match" involves two fields, so there is no attribute for it. setCustomValidity() fills that gap. A non-empty string marks the field invalid with the customError flag, and an empty string clears it.
Choosing when to show errors
Showing an error while someone is still typing their first character is hostile. The demo validates on focusout, when the field loses focus. After a field has been marked invalid, the input listener re-checks it on every keystroke, so the error vanishes the moment the value is fixed. On submit everything is checked at once, and focus moves to the first problem.
Common problems and how to fix them
The browser's default bubble still appears
You forgot novalidate on the form. With it present, the submit handler runs even when fields are invalid, and your messages replace the bubble.
A field stays invalid after the user fixes it
This is nearly always setCustomValidity. Once you set a message, the field remains invalid until you set the empty string. Call it on every check, as checkMatch does, not only when there is a problem.
minlength never triggers on a prefilled value
tooShort is only reported after a user edits the field. A value set from script is not flagged by minlength. If you prefill fields, check their length yourself as well.
Screen reader users do not hear the error
Visible red text alone is not enough. The aria-describedby link and aria-invalid="true" are what connect the message to the field. Moving focus to the first invalid input on submit means the error is read straight away.
The pattern error message is cryptic
The default text for pattern is something like "Please match the requested format." Always replace it with your own sentence, as the patternMismatch entry does.
Do not stop at the browser
Client-side validation makes forms pleasant. It is not security. Anyone can send a request without your page, so the server must check every value again. Treat this code as the friendly first layer.