Skip to main content

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

AspectClient-sideServer-side
WhenIn the browser before submissionOn the server after submission
TechnologyHTML5 attributes, JavaScriptAny backend language (PHP, Node.js, Python, etc.)
User feedbackInstant, no page reloadRequires a round-trip to the server
Security❌ Can be bypassed (user can disable JS)Always required — the final gatekeeper
PerformanceReduces unnecessary server requestsHandles 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:

PropertyDescription
valueMissingRequired field is empty
typeMismatchValue doesn't match the input type (e.g., bad email)
patternMismatchValue doesn't match the pattern regex
tooLongValue exceeds maxlength
tooShortValue is shorter than minlength
rangeUnderflowValue is less than min
rangeOverflowValue is greater than max
stepMismatchValue doesn't match the step increment
badInputBrowser can't convert the value
customErrorA custom validity message was set via setCustomValidity()
validEverything 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: :invalid styles apply as soon as the page loads for required empty fields. To avoid showing errors before the user interacts, combine these with classes toggled by JavaScript or use the :not(:focus):invalid selector.


Accessible Validation

Validation must be perceivable by all users, including those using assistive technologies:

ARIA Attributes for Validation

AttributePurpose
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"
placeholder="[email protected]"
>
<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:

  1. Name — required, 3–50 characters
  2. Email — required, valid email format
  3. Rating — required, 1–5 (use type="number")
  4. Message — required, 10–500 characters
  5. Custom error messages for every validation rule
  6. Accessiblearia-required, aria-describedby, aria-invalid, role="alert"
  7. Styling — red border on invalid, green on valid
  8. Real-time validation — errors update as the user types

Hint: Use <form novalidate> and the Constraint Validation API. Check the validity object properties to show specific messages. Use CSS :valid and :invalid pseudo-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.


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.