Most teams reach for a JavaScript validation library the moment a form needs more than a single required field. But browsers have shipped a surprisingly capable validation system since HTML5: the Constraint Validation API. It handles required fields, type checking, length limits, numeric ranges, and custom patterns natively, ships zero bytes of JavaScript, and works before your bundle has even finished downloading. This guide walks through building a fully validated, accessible signup form using only native HTML — with a small, optional layer of JavaScript for custom error messages.
Why native validation still matters
Client-side validation libraries solve a real problem, but they come with real costs: extra kilobytes, a dependency to maintain, and validation logic that silently breaks if JavaScript fails to load. Native HTML validation runs in the browser’s rendering engine, works with assistive technology out of the box, and degrades gracefully — if JavaScript never loads, your required and pattern attributes still block a broken submission. It’s not a replacement for server-side validation (never trust the client), but it’s the correct first line of defense.
The core attributes
Every native validation rule is expressed as a plain HTML attribute. No event listeners, no state management.
<form novalidate id="signup-form">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
/>
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
maxlength="20"
pattern="[a-zA-Z0-9_]+"
autocomplete="username"
/>
<label for="age">Age</label>
<input
type="number"
id="age"
name="age"
min="13"
max="120"
required
/>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
minlength="10"
autocomplete="new-password"
/>
<button type="submit">Create account</button>
</form>
Each attribute maps to a specific constraint the browser checks automatically on submit: required rejects empty values, type="email" checks for a valid email shape, minlength/maxlength bound string length, min/max bound numeric ranges, and pattern takes a regular expression the value must fully match. The novalidate attribute on the <form> element is optional — add it only if you plan to intercept submission with JavaScript to customize error display, which we’ll do next.
Reading validation state with the Constraint Validation API
Every form control exposes a validity object and a checkValidity() method. You don’t need this for validation to function — the browser already blocks invalid submissions — but you need it to customize how errors are shown.
const form = document.getElementById('signup-form');
form.addEventListener('submit', (event) => {
if (!form.checkValidity()) {
event.preventDefault();
// Focus and announce the first invalid field
const firstInvalid = form.querySelector(':invalid');
firstInvalid?.focus();
}
});
The validity object on each input tells you exactly which constraint failed: valueMissing, typeMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, and patternMismatch are all boolean flags you can inspect individually. This is what lets you write custom, field-specific error copy instead of relying on the browser’s default (and sometimes generic) validation bubble.
Custom error messages with setCustomValidity
The default “Please fill out this field” message is functional but generic. You can override it per-field with setCustomValidity(), which also lets you implement checks the browser can’t express declaratively — like confirming two passwords match.
const username = document.getElementById('username');
username.addEventListener('input', () => {
if (username.validity.patternMismatch) {
username.setCustomValidity(
'Usernames can only contain letters, numbers, and underscores.'
);
} else if (username.validity.tooShort) {
username.setCustomValidity('Username must be at least 3 characters.');
} else {
username.setCustomValidity(''); // clear the error once valid
}
});
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirm-password');
function checkPasswordsMatch() {
if (confirmPassword.value !== password.value) {
confirmPassword.setCustomValidity('Passwords do not match.');
} else {
confirmPassword.setCustomValidity('');
}
}
password.addEventListener('input', checkPasswordsMatch);
confirmPassword.addEventListener('input', checkPasswordsMatch);
Calling setCustomValidity('') is critical — an empty string clears the error and marks the field valid again. Forgetting this line is the most common bug in custom validation code: the field gets permanently stuck as invalid even after the user fixes it.
Styling valid and invalid states with CSS
Native validation ships its own CSS pseudo-classes, so you can style feedback without touching JavaScript at all:
input:invalid {
border-color: #dc2626;
}
input:valid {
border-color: #16a34a;
}
/* Only show red borders after the user has interacted with the field */
input:not(:placeholder-shown):invalid {
border-color: #dc2626;
}
/* Modern browsers: only after a real interaction, not on page load */
input:user-invalid {
border-color: #dc2626;
background-color: #fef2f2;
}
Plain :invalid matches the moment the page loads, which means an empty required field shows as “invalid” before the user has typed anything — a poor experience. The :user-invalid pseudo-class (supported in all current major browsers) only applies after the user has interacted with the field and left it in an invalid state, which matches what users actually expect from good form design.
Accessible error messages
Visual styling alone isn’t enough — screen reader users need errors announced and associated with their field. Pair each input with a live region tied via aria-describedby:
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-error"
/>
<span id="email-error" role="alert" class="error-text"></span>
email.addEventListener('invalid', (event) => {
event.preventDefault(); // suppress the native bubble
const errorEl = document.getElementById('email-error');
errorEl.textContent = email.validity.valueMissing
? 'Email is required.'
: 'Enter a valid email address, like name@example.com.';
});
email.addEventListener('input', () => {
if (email.validity.valid) {
document.getElementById('email-error').textContent = '';
}
});
The role="alert" attribute makes the error span a live region, so assistive technology announces new text automatically without requiring focus to move. Listening for the invalid event (fired per-field when the browser’s own validation fails) lets you suppress the default tooltip with event.preventDefault() while still keeping all the built-in constraint logic.
When to still use JavaScript validation
Native validation covers format, length, range, and required-field checks well, but it has limits. Cross-field rules (password confirmation, “end date after start date”), asynchronous checks (username availability against an API), and conditional requirements (a field required only if a checkbox is checked) all need a small amount of JavaScript layered on top — as shown above with setCustomValidity. The key is that JavaScript should enhance the native constraints, not replace them. Keep the HTML attributes in place so the form still enforces basic rules even if a script fails to load or execute.
Server-side validation is still non-negotiable
None of this replaces validating input on the server. Any client-side check — native or JavaScript — can be bypassed by a direct API request, a modified DOM, or a disabled script. Treat HTML validation as a UX layer that gives users fast, accessible feedback, and treat your server as the actual security and data-integrity boundary.
Conclusion
The Constraint Validation API is one of the most underused parts of the HTML platform. A handful of attributes — required, pattern, minlength, min/max — combined with :user-invalid styling and a thin layer of setCustomValidity() for custom messages, gets you a form that’s faster, more accessible, and more resilient than most JavaScript validation libraries, with a fraction of the code. Reach for a library only when you hit genuine gaps — async checks or complex cross-field logic — and let the browser handle the rest.