HTML Form Validation — Complete Guide
Mentor's Note: Form validation is like having a friendly checkout assistant who catches mistakes before you leave the store! HTML5 gives us powerful built-in tools to validate user input, and with a sprinkle of JavaScript, we can create polished, accessible, frustration-free forms. 🛡️✨
📚 Educational Content: This comprehensive guide covers everything from HTML5 built-in validation attributes to the Constraint Validation API, custom validation, accessible error messages, and professional best practices.
Why Validation Matters
Validation ensures the data users submit meets your requirements before it ever reaches your server. This:
- Improves user experience — catches typos and mistakes immediately
- Reduces server load — fewer invalid submissions to process
- Prevents bad data — keeps databases clean and consistent
- Enhances security — prevents malformed input from reaching your backend
Client-side vs Server-side Validation
| Aspect | Client-side | Server-side |
|---|---|---|
| When | In the browser before submission | On the server after submission |
| Technology | HTML5 attributes, JavaScript | Any backend language (PHP, Node.js, Python, etc.) |
| User feedback | Instant, no page reload | Requires a round-trip to the server |
| Security | ❌ Can be bypassed (user can disable JS) | ✅ Always required — the final gatekeeper |
| Performance | Reduces unnecessary server requests | Handles what passes through client-side |
Key rule: Always validate on both sides. Client-side for UX, server-side for security. Never trust user input — even with client-side validation in place!
HTML5 Built-in Validation
HTML5 provides validation attributes that work without any JavaScript. The browser handles everything:
required
Prevents submission when the field is empty.
<input type="text" name="username" required>
<textarea name="message" required></textarea>
<select name="country" required>
<option value="">-- Select --</option>
<option value="in">India</option>
</select>
minlength / maxlength
Enforces string length constraints.
<label for="username">Username (3–20 characters):</label>
<input type="text" id="username" name="username"
minlength="3" maxlength="20" required>
min / max
Sets numeric or date boundaries. Works with number, range, date, datetime-local, month, time, and week.
<label for="age">Age (18–120):</label>
<input type="number" id="age" name="age" min="18" max="120" required>
<label for="birthdate">Date of birth:</label>
<input type="date" id="birthdate" name="birthdate"
min="1900-01-01" max="2010-12-31" required>
type Validation
Certain input types have built-in format validation:
<!-- Must contain '@' and a domain -->
<input type="email" name="email" required>
<!-- Must be a valid URL (http:// or https://) -->
<input type="url" name="website">
<!-- Must be a valid number -->
<input type="number" name="quantity" min="1" max="100">
<!-- Must be a valid telephone number (pattern varies by locale) -->
<input type="tel" name="phone">
pattern (Regex Validation)
The pattern attribute lets you define a custom regular expression the value must match.
<!-- Indian mobile number (10 digits starting with 6-9) -->
<label for="mobile">Mobile Number:</label>
<input type="tel" id="mobile" name="mobile"
pattern="[6-9][0-9]{9}"
title="Enter a valid 10-digit Indian mobile number"
required>
<!-- Alphanumeric username (letters, numbers, underscore only) -->
<label for="user">Username:</label>
<input type="text" id="user" name="user"
pattern="[A-Za-z0-9_]{3,20}"
title="3-20 alphanumeric characters or underscores"
required>
<!-- PIN code (6 digits) -->
<label for="pincode">PIN Code:</label>
<input type="text" id="pincode" name="pincode"
pattern="[0-9]{6}"
title="Enter a 6-digit PIN code"
required>
Validation Flow
Constraint Validation API
The Constraint Validation API gives JavaScript access to the browser's built-in validation engine. You can check validity programmatically, customize messages, and build real-time validation experiences.
checkValidity()
Returns true if the element's value passes all constraints, false otherwise. Also triggers the element's invalid event.
const email = document.getElementById('email');
if (email.checkValidity()) {
console.log('Email is valid!');
} else {
console.log('Email is invalid!');
}
reportValidity()
Same as checkValidity(), but also shows the browser's built-in error bubble if invalid.
const form = document.getElementById('myForm');
if (!form.reportValidity()) {
// The browser will display validation errors
return;
}
// Proceed with submission...
setCustomValidity(message)
Sets a custom validation error. Pass an empty string to clear it.
const password = document.getElementById('password');
const confirm = document.getElementById('confirm');
confirm.addEventListener('input', function () {
if (this.value !== password.value) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
});
validationMessage
Returns the browser's localised error message for the current invalid state.
const nameInput = document.getElementById('name');
if (!nameInput.checkValidity()) {
alert(nameInput.validationMessage);
}
The validity Object
The validity property is a ValidityState object with boolean properties:
| Property | Description |
|---|---|
valueMissing | Required field is empty |
typeMismatch | Value doesn't match the input type (e.g., bad email) |
patternMismatch | Value doesn't match the pattern regex |
tooLong | Value exceeds maxlength |
tooShort | Value is shorter than minlength |
rangeUnderflow | Value is less than min |
rangeOverflow | Value is greater than max |
stepMismatch | Value doesn't match the step increment |
badInput | Browser can't convert the value |
customError | A custom validity message was set via setCustomValidity() |
valid | Everything is good! |
// Checking specific validity states
const input = document.getElementById('phone');
input.addEventListener('input', function () {
const validity = this.validity;
if (validity.valueMissing) {
showError('Phone number is required');
} else if (validity.patternMismatch) {
showError('Format: 9876543210 (10 digits starting with 6-9)');
} else if (validity.valid) {
clearError();
}
});
Custom Validation Messages
The browser's default error messages are generic and vary by locale. You can override them with clearer, user-friendly messages:
Method 1: Using setCustomValidity() on submit
<form id="signupForm" novalidate>
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<span id="emailError" class="error-message"></span>
<button type="submit">Sign Up</button>
</form>
<script>
const form = document.getElementById('signupForm');
const email = document.getElementById('email');
const emailError = document.getElementById('emailError');
form.addEventListener('submit', function (e) {
e.preventDefault();
if (email.validity.valueMissing) {
email.setCustomValidity('Please enter your email address');
} else if (email.validity.typeMismatch) {
email.setCustomValidity('Enter a valid email (e.g., [email protected])');
} else {
email.setCustomValidity('');
}
emailError.textContent = email.validationMessage;
if (email.checkValidity()) {
this.submit();
}
});
</script>
Method 2: Real-time validation with custom messages
<form id="profileForm" novalidate>
<div class="field">
<label for="age">Age:</label>
<input type="number" id="age" name="age" min="18" max="120" required>
<span id="ageError" class="error-message"></span>
</div>
<button type="submit">Submit</button>
</form>
<script>
const ageInput = document.getElementById('age');
const ageError = document.getElementById('ageError');
const ageMessages = {
valueMissing: 'Age is required',
rangeUnderflow: 'You must be at least 18 years old',
rangeOverflow: 'Age cannot exceed 120'
};
ageInput.addEventListener('input', function () {
const state = this.validity;
if (state.valueMissing) {
this.setCustomValidity(ageMessages.valueMissing);
} else if (state.rangeUnderflow) {
this.setCustomValidity(ageMessages.rangeUnderflow);
} else if (state.rangeOverflow) {
this.setCustomValidity(ageMessages.rangeOverflow);
} else {
this.setCustomValidity('');
}
ageError.textContent = this.validationMessage;
});
</script>
Styling Valid/Invalid States
CSS pseudo-classes let you style form controls based on their validation state:
/* Default input styling */
input, textarea, select {
border: 2px solid #d1d5db;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
/* Valid input — green border */
input:valid,
textarea:valid,
select:valid {
border-color: #10b981;
}
/* Invalid input — red border */
input:invalid,
textarea:invalid,
select:invalid {
border-color: #ef4444;
}
/* Required fields — subtle indicator */
input:required,
textarea:required,
select:required {
background: #fefce8;
}
/* Optional fields */
input:optional,
textarea:optional,
select:optional {
background: #ffffff;
}
/* In-range / out-of-range */
input:in-range {
border-color: #10b981;
}
input:out-of-range {
border-color: #ef4444;
}
/* Focused invalid — stronger visual */
input:focus:invalid {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
outline: none;
}
input:focus:valid {
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.25);
outline: none;
}
/* Error message styling */
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
/* Success message styling */
.success-message {
color: #10b981;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
Note:
:invalidstyles apply as soon as the page loads forrequiredempty fields. To avoid showing errors before the user interacts, combine these with classes toggled by JavaScript or use the:not(:focus):invalidselector.
Accessible Validation
Validation must be perceivable by all users, including those using assistive technologies:
ARIA Attributes for Validation
| Attribute | Purpose |
|---|---|
aria-required="true" | Communicates that a field is required (screen readers announce this) |
aria-invalid="true" | Marks a field as having an error |
aria-describedby="error-id" | Links the field to its error message element |
role="alert" | Makes a dynamically added error message immediately announced |
aria-live="polite" | Announces changes in the error region without interrupting |
Practical Example
<div class="field">
<label for="email">
Email <span aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-describedby="email-error"
aria-invalid="false"
>
<span
id="email-error"
class="error-message"
role="alert"
aria-live="polite"
></span>
</div>
<script>
const email = document.getElementById('email');
const error = document.getElementById('email-error');
email.addEventListener('input', function () {
if (!this.checkValidity()) {
if (this.validity.valueMissing) {
error.textContent = 'Email address is required';
} else if (this.validity.typeMismatch) {
error.textContent = 'Enter a valid email address';
}
this.setAttribute('aria-invalid', 'true');
} else {
error.textContent = '';
this.setAttribute('aria-invalid', 'false');
}
});
</script>
Accessible Validation Flow
Real Example — Complete Validated Form
Here's a complete registration form with HTML5 validation, custom messages, accessibility, and styling:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registration Form</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 480px;
margin: 2rem auto;
padding: 0 1rem;
}
form {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
h1 { margin-top: 0; font-size: 1.5rem; }
.field { margin-bottom: 1.25rem; }
label {
display: block;
margin-bottom: 0.375rem;
font-weight: 600;
font-size: 0.9rem;
}
.required::after {
content: ' *';
color: #ef4444;
}
input, select, textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
}
input:valid, select:valid, textarea:valid {
border-color: #10b981;
}
input:invalid:not(:placeholder-shown),
select:invalid:not(:placeholder-shown),
textarea:invalid:not(:placeholder-shown) {
border-color: #ef4444;
}
.error-message {
color: #ef4444;
font-size: 0.8rem;
margin-top: 0.25rem;
display: block;
min-height: 1.2em;
}
button {
width: 100%;
padding: 0.75rem;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #4f46e5; }
button:focus-visible {
outline: 3px solid #a5b4fc;
outline-offset: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
</style>
</head>
<body>
<form id="registration" novalidate>
<h1>Create Account</h1>
<div class="field">
<label for="name" class="required">Full Name</label>
<input
type="text"
id="name"
name="name"
required
minlength="2"
maxlength="50"
aria-required="true"
aria-describedby="name-error"
placeholder="e.g. Priya Sharma"
>
<span id="name-error" class="error-message" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="email" class="required">Email</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-describedby="email-error"
>
<span id="email-error" class="error-message" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="phone" class="required">Mobile Number</label>
<input
type="tel"
id="phone"
name="phone"
required
pattern="[6-9][0-9]{9}"
aria-required="true"
aria-describedby="phone-error"
placeholder="9876543210"
title="10-digit number starting with 6-9"
>
<span id="phone-error" class="error-message" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="age">Age</label>
<input
type="number"
id="age"
name="age"
min="13"
max="120"
aria-describedby="age-error"
placeholder="18"
>
<span id="age-error" class="error-message" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="password" class="required">Password</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
maxlength="64"
aria-required="true"
aria-describedby="password-error"
placeholder="At least 8 characters"
>
<span id="password-error" class="error-message" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="confirm" class="required">Confirm Password</label>
<input
type="password"
id="confirm"
name="confirm"
required
aria-required="true"
aria-describedby="confirm-error"
placeholder="Re-enter password"
>
<span id="confirm-error" class="error-message" role="alert" aria-live="polite"></span>
</div>
<button type="submit">Register</button>
</form>
<script>
const form = document.getElementById('registration');
const fields = {
name: {
messages: {
valueMissing: 'Full name is required',
tooShort: 'Name must be at least 2 characters',
tooLong: 'Name must be under 50 characters'
}
},
email: {
messages: {
valueMissing: 'Email address is required',
typeMismatch: 'Enter a valid email address (e.g., [email protected])'
}
},
phone: {
messages: {
valueMissing: 'Mobile number is required',
patternMismatch: 'Enter a valid 10-digit Indian mobile number starting with 6-9'
}
},
age: {
checkOnBlur: true,
messages: {
rangeUnderflow: 'You must be at least 13 years old',
rangeOverflow: 'Age cannot exceed 120'
}
},
password: {
messages: {
valueMissing: 'Password is required',
tooShort: 'Password must be at least 8 characters',
tooLong: 'Password must be under 64 characters'
}
},
confirm: {
messages: {
valueMissing: 'Please confirm your password'
},
customValidator: function (input, formData) {
const password = formData.get('password');
if (input.value && input.value !== password) {
input.setCustomValidity('Passwords do not match');
return 'Passwords do not match';
}
input.setCustomValidity('');
return '';
}
}
};
function getCustomMessage(input, fieldConfig, formData) {
const state = input.validity;
const msgs = fieldConfig.messages || {};
if (state.valueMissing) return msgs.valueMissing || 'This field is required';
if (state.typeMismatch) return msgs.typeMismatch || 'Invalid format';
if (state.patternMismatch) return msgs.patternMismatch || 'Invalid format';
if (state.tooShort) return msgs.tooShort || 'Value is too short';
if (state.tooLong) return msgs.tooLong || 'Value is too long';
if (state.rangeUnderflow) return msgs.rangeUnderflow || 'Value is too low';
if (state.rangeOverflow) return msgs.rangeOverflow || 'Value is too high';
if (state.stepMismatch) return msgs.stepMismatch || 'Invalid step value';
if (fieldConfig.customValidator) {
return fieldConfig.customValidator(input, formData);
}
return '';
}
function validateField(input) {
const name = input.name;
const fieldConfig = fields[name];
if (!fieldConfig) return;
const formData = new FormData(form);
const msg = getCustomMessage(input, fieldConfig, formData);
const errorEl = document.getElementById(name + '-error');
if (msg) {
input.setCustomValidity(msg);
errorEl.textContent = msg;
input.setAttribute('aria-invalid', 'true');
} else {
input.setCustomValidity('');
errorEl.textContent = '';
input.setAttribute('aria-invalid', 'false');
}
}
// Real-time validation on input
for (const name in fields) {
const input = document.getElementById(name);
const config = fields[name];
if (config.checkOnBlur) {
input.addEventListener('blur', function () {
validateField(this);
});
} else {
input.addEventListener('input', function () {
validateField(this);
});
}
}
// Validate all on submit
form.addEventListener('submit', function (e) {
e.preventDefault();
let firstError = null;
for (const name in fields) {
const input = document.getElementById(name);
validateField(input);
if (!input.checkValidity() && !firstError) {
firstError = input;
}
}
if (firstError) {
firstError.focus();
} else {
alert('🎉 Form submitted successfully!');
this.reset();
}
});
</script>
</body>
</html>
Common Mistakes
❌ Showing errors on untouched fields
/* BAD: Shows error immediately on page load */
input:invalid { border-color: red; }
/* BETTER: Only show error after user has typed something */
input:invalid:not(:placeholder-shown) { border-color: red; }
❌ Only checking validity.valid
// BAD: Too vague — what went wrong?
if (!input.validity.valid) {
showError('Invalid input');
}
// GOOD: Check specific properties
if (input.validity.valueMissing) {
showError('This field is required');
} else if (input.validity.patternMismatch) {
showError('Invalid format');
}
❌ Not resetting setCustomValidity()
// BAD: Old custom error persists even when input becomes valid
input.addEventListener('input', function () {
if (this.value.length < 3) {
this.setCustomValidity('Too short');
}
// If value is now >= 3, the old error still shows!
});
// GOOD: Always clear when valid
input.addEventListener('input', function () {
if (this.value.length < 3) {
this.setCustomValidity('Too short');
} else {
this.setCustomValidity('');
}
});
❌ Forgetting novalidate when using custom validation
<!-- BAD: Browser shows its own error bubbles AND your custom messages -->
<form id="myForm">
<input type="email" id="email" required>
<span id="email-error"></span>
</form>
<!-- GOOD: Disable built-in UI so you can show your own -->
<form id="myForm" novalidate>
<input type="email" id="email" required>
<span id="email-error"></span>
</form>
❌ Not making errors accessible
<!-- BAD: Screen reader users won't know about the error -->
<span class="error-message">Email is invalid</span>
<!-- GOOD: Screen reader announces the error -->
<span class="error-message" role="alert" aria-live="polite">Email is invalid</span>
Try It Yourself
Build a feedback form with the following requirements:
- Name — required, 3–50 characters
- Email — required, valid email format
- Rating — required, 1–5 (use
type="number") - Message — required, 10–500 characters
- Custom error messages for every validation rule
- Accessible —
aria-required,aria-describedby,aria-invalid,role="alert" - Styling — red border on invalid, green on valid
- Real-time validation — errors update as the user types
Hint: Use
<form novalidate>and the Constraint Validation API. Check thevalidityobject properties to show specific messages. Use CSS:validand:invalidpseudo-classes for visual feedback.
Click to see a solution outline
<form id="feedbackForm" novalidate>
<div class="field">
<label for="feedback-name">Name</label>
<input type="text" id="feedback-name" name="name"
required minlength="3" maxlength="50"
aria-required="true" aria-describedby="feedback-name-error">
<span id="feedback-name-error" class="error" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="feedback-email">Email</label>
<input type="email" id="feedback-email" name="email"
required aria-required="true" aria-describedby="feedback-email-error">
<span id="feedback-email-error" class="error" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="feedback-rating">Rating (1–5)</label>
<input type="number" id="feedback-rating" name="rating"
required min="1" max="5" aria-required="true"
aria-describedby="feedback-rating-error">
<span id="feedback-rating-error" class="error" role="alert" aria-live="polite"></span>
</div>
<div class="field">
<label for="feedback-message">Message</label>
<textarea id="feedback-message" name="message"
required minlength="10" maxlength="500"
aria-required="true" aria-describedby="feedback-message-error"></textarea>
<span id="feedback-message-error" class="error" role="alert" aria-live="polite"></span>
</div>
<button type="submit">Submit Feedback</button>
</form>
Write the JavaScript using the Constraint Validation API pattern shown in the real example above.
🔗 Related Topics
- HTML Forms — Form structure, input types, and attributes
- JavaScript in HTML — Dynamic validation with JavaScript
- Accessibility in Forms — Make your forms usable by everyone
Remember: Good validation is invisible when everything works, but incredibly helpful when something goes wrong. Validate on both client and server, write clear messages, and always think about accessibility.