HTML5 Form Validation
1. Required Fields (required attribute) and Validation States
| Attribute/Pseudo-class | Purpose | Applied To | Behavior |
|---|---|---|---|
| required | Mark field as mandatory | input, select, textarea | Prevents submission if empty |
| :valid | Style valid inputs | All form controls | Matches when validation passes |
| :invalid | Style invalid inputs | All form controls | Matches when validation fails |
| :required | Style required fields | input, select, textarea | Matches fields with required attribute |
| :optional | Style optional fields | input, select, textarea | Matches fields without required |
| :user-invalid | Style invalid after user interaction | All form controls | Only triggers after user edits/blurs |
| :placeholder-shown | When placeholder is visible | input, textarea | Empty field showing placeholder |
| :blank | Empty field (experimental) | input, textarea | Field has no value |
Validation Timing
| When | What Validates |
|---|---|
| On Submit | All fields checked, first error focused |
| On Blur | Individual field (with JS) |
| On Input | Real-time validation (with JS) |
| Immediately | Type-specific validation (email, url) |
Validity States (JS)
| Property | Description |
|---|---|
| valid | All constraints satisfied |
| valueMissing | Required field is empty |
| typeMismatch | Type doesn't match (email, url) |
| patternMismatch | Pattern constraint failed |
| tooLong | Exceeds maxlength |
| tooShort | Below minlength |
| rangeUnderflow | Below min value |
| rangeOverflow | Above max value |
| stepMismatch | Not a valid step |
Example: Required fields and validation states
<!-- HTML: Required fields -->
<form id="signupForm">
<label for="email">Email (required):</label>
<input type="email" id="email" name="email" required>
<label for="username">Username (required):</label>
<input type="text" id="username" name="username" required minlength="3">
<label for="age">Age (optional):</label>
<input type="number" id="age" name="age" min="13">
<button type="submit">Sign Up</button>
</form>
<!-- CSS: Validation state styling -->
<style>
/* Style all required fields */
input:required {
border-left: 3px solid #ff9800;
}
/* Valid state (avoid on page load) */
input:not(:placeholder-shown):valid {
border-color: #4caf50;
background-image: url('check-icon.svg');
background-repeat: no-repeat;
background-position: right 10px center;
}
/* Invalid state (only after user interaction) */
input:user-invalid,
input:not(:placeholder-shown):invalid {
border-color: #f44336;
background-color: #ffebee;
}
/* Focus states */
input:invalid:focus {
outline: 2px solid #f44336;
}
input:valid:focus {
outline: 2px solid #4caf50;
}
/* Optional fields (subtle styling) */
input:optional {
border-left: 3px solid #ccc;
}
</style>
<!-- JavaScript: Check validity -->
<script>
const form = document.getElementById('signupForm');
const email = document.getElementById('email');
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
alert('Please fill in all required fields correctly.');
}
});
// Check individual field validity
email.addEventListener('blur', () => {
const validity = email.validity;
if (validity.valueMissing) {
console.log('Email is required');
} else if (validity.typeMismatch) {
console.log('Invalid email format');
} else if (validity.valid) {
console.log('Email is valid');
}
});
</script>
Warning:
:invalid triggers immediately on page load for empty required fields. Use
:user-invalid or :not(:placeholder-shown):invalid to only style after user
interaction.
2. Pattern Validation (pattern attribute) and Regular Expressions
| Pattern | Regex | Use Case | Example |
|---|---|---|---|
| ZIP Code (US) | [0-9]{5} |
5-digit postal code | 12345 |
| ZIP+4 | [0-9]{5}(-[0-9]{4})? |
Extended ZIP | 12345-6789 |
| Phone (US) | \d{3}-\d{3}-\d{4} |
Formatted phone | 555-123-4567 |
| Phone (Flexible) | [\d\s\-\(\)]+ |
Various formats | (555) 123-4567 |
| Credit Card | \d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4} |
16-digit card | 1234-5678-9012-3456 |
| Username | [a-zA-Z0-9_]{3,16} |
Alphanumeric + underscore | user_name123 |
| Hex Color | #?([a-fA-F0-9]{6}|[a-fA-F0-9]{3}) |
Hex color code | #ff5733 |
| URL Slug | [a-z0-9]+(?:-[a-z0-9]+)* |
URL-friendly string | my-article-title |
| IPv4 | ^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$ |
IP address | 192.168.1.1 |
| Strong Password | ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$ |
Min 8, upper, lower, digit, special | Pass@123 |
| Date (MM/DD/YYYY) | (0[1-9]|1[0-2])\/(0[1-9]|[12][0-9]|3[01])\/\d{4} |
US date format | 12/31/2024 |
| Time (24hr) | ([01]?[0-9]|2[0-3]):[0-5][0-9] |
HH:MM format | 14:30 |
Example: Pattern validation with helpful messages
<!-- ZIP Code -->
<label for="zip">ZIP Code:</label>
<input type="text" id="zip" name="zip"
pattern="[0-9]{5}"
title="Please enter a 5-digit ZIP code"
placeholder="12345"
required>
<!-- Phone Number -->
<label for="phone">Phone:</label>
<input type="tel" id="phone" name="phone"
pattern="\d{3}-\d{3}-\d{4}"
title="Format: 555-123-4567"
placeholder="555-123-4567">
<!-- Username -->
<label for="username">Username:</label>
<input type="text" id="username" name="username"
pattern="[a-zA-Z0-9_]{3,16}"
title="3-16 characters: letters, numbers, and underscores only"
placeholder="user_name"
required>
<!-- Strong Password -->
<label for="password">Password:</label>
<input type="password" id="password" name="password"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$"
title="Minimum 8 characters, at least one uppercase, lowercase, number, and special character"
required>
<!-- Credit Card -->
<label for="card">Credit Card:</label>
<input type="text" id="card" name="card"
pattern="\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}"
title="Enter 16-digit card number"
placeholder="1234-5678-9012-3456"
maxlength="19">
<!-- URL Slug -->
<label for="slug">Article Slug:</label>
<input type="text" id="slug" name="slug"
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
title="Lowercase letters, numbers, and hyphens only"
placeholder="my-article-title">
<!-- Hex Color -->
<label for="color">Hex Color:</label>
<input type="text" id="color" name="color"
pattern="#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"
title="Enter hex color code (e.g., #ff5733 or #fff)"
placeholder="#ff5733">
<!-- Custom validation message (JavaScript) -->
<script>
const usernameInput = document.getElementById('username');
usernameInput.addEventListener('invalid', (e) => {
if (usernameInput.validity.patternMismatch) {
usernameInput.setCustomValidity('Username must be 3-16 characters and contain only letters, numbers, and underscores.');
} else {
usernameInput.setCustomValidity('');
}
});
usernameInput.addEventListener('input', () => {
usernameInput.setCustomValidity('');
});
</script>
Note: Always include
title attribute with pattern - it's shown in the
validation message. Regex in pattern is automatically anchored (^...$), so don't add anchors. Use
setCustomValidity() for better error messages.
3. Custom Validation Messages (setCustomValidity) and Styling
| Method/Property | Purpose | Usage | Returns/Effect |
|---|---|---|---|
| checkValidity() | Check if element is valid | element.checkValidity() |
Boolean (true/false) |
| reportValidity() | Check and show validation UI | element.reportValidity() |
Boolean + shows browser message |
| setCustomValidity() | Set custom error message | element.setCustomValidity('msg') |
Sets validation message |
| validationMessage | Get current validation message | element.validationMessage |
String (error message) |
| validity | Access validity state object | element.validity.valid |
ValidityState object |
ValidityState Properties
| Property | Triggered By |
|---|---|
| valueMissing | required field empty |
| typeMismatch | Invalid email/url format |
| patternMismatch | pattern not matched |
| tooLong | Exceeds maxlength |
| tooShort | Below minlength |
| rangeUnderflow | Below min |
| rangeOverflow | Above max |
| stepMismatch | Invalid step |
| customError | setCustomValidity() called |
Styling Strategies:
- Use
:invalid/:validfor CSS styling - Combine with
:focusfor interactive feedback - Use
:user-invalidfor post-interaction styling - Add icons/colors to indicate validation state
- Show inline error messages below fields
- Use
aria-invalidfor accessibility
Example: Custom validation messages and styling
<!-- HTML: Form with custom validation -->
<form id="customForm" novalidate>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<span class="error-message" id="email-error"></span>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required minlength="8">
<span class="error-message" id="password-error"></span>
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password:</label>
<input type="password" id="confirm-password" name="confirm-password" required>
<span class="error-message" id="confirm-error"></span>
</div>
<button type="submit">Submit</button>
</form>
<!-- CSS: Custom validation styling -->
<style>
.form-group {
margin-bottom: 20px;
position: relative;
}
input {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 4px;
transition: border-color 0.3s;
}
/* Valid state */
input:valid:not(:placeholder-shown) {
border-color: #4caf50;
}
input:valid:not(:placeholder-shown)::after {
content: '✓';
color: #4caf50;
position: absolute;
right: 10px;
}
/* Invalid state */
input:invalid:not(:placeholder-shown),
input[aria-invalid="true"] {
border-color: #f44336;
background-color: #ffebee;
}
/* Error message styling */
.error-message {
display: none;
color: #f44336;
font-size: 0.875em;
margin-top: 5px;
}
.error-message.visible {
display: block;
}
/* Focus states */
input:focus {
outline: none;
border-color: #2196f3;
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
}
</style>
<!-- JavaScript: Custom validation logic -->
<script>
const form = document.getElementById('customForm');
const email = document.getElementById('email');
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirm-password');
// Custom error messages
const errorMessages = {
valueMissing: 'This field is required.',
typeMismatch: {
email: 'Please enter a valid email address.',
url: 'Please enter a valid URL.'
},
tooShort: (minLength) => `Please enter at least ${minLength} characters.`,
patternMismatch: 'Please match the requested format.',
};
// Validate individual field
function validateField(field) {
const errorElement = document.getElementById(`${field.id}-error`);
const validity = field.validity;
let errorMessage = '';
if (validity.valueMissing) {
errorMessage = errorMessages.valueMissing;
} else if (validity.typeMismatch) {
errorMessage = errorMessages.typeMismatch[field.type] || 'Invalid format.';
} else if (validity.tooShort) {
errorMessage = errorMessages.tooShort(field.minLength);
} else if (validity.patternMismatch) {
errorMessage = field.title || errorMessages.patternMismatch;
}
// Custom password match validation
if (field === confirmPassword && field.value !== password.value) {
errorMessage = 'Passwords do not match.';
field.setCustomValidity(errorMessage);
} else if (field === confirmPassword) {
field.setCustomValidity('');
}
// Display error
if (errorMessage) {
errorElement.textContent = errorMessage;
errorElement.classList.add('visible');
field.setAttribute('aria-invalid', 'true');
return false;
} else {
errorElement.textContent = '';
errorElement.classList.remove('visible');
field.removeAttribute('aria-invalid');
return true;
}
}
// Validate on blur
[email, password, confirmPassword].forEach(field => {
field.addEventListener('blur', () => validateField(field));
field.addEventListener('input', () => {
if (field.getAttribute('aria-invalid') === 'true') {
validateField(field);
}
});
});
// Validate on submit
form.addEventListener('submit', (e) => {
e.preventDefault();
const fields = [email, password, confirmPassword];
const isValid = fields.every(validateField);
if (isValid) {
console.log('Form is valid, submitting...');
// form.submit();
} else {
// Focus first invalid field
const firstInvalid = fields.find(field => !field.validity.valid);
firstInvalid?.focus();
}
});
</script>
Warning: When using
setCustomValidity(), you must clear it with
setCustomValidity('') when the field becomes valid, or the field will remain invalid even if the
constraint is satisfied.
4. HTML5 Input Constraints (min, max, step) and Limits
| Constraint | Attribute | Applies To | Example |
|---|---|---|---|
| Required | required |
Most inputs, select, textarea | <input required> |
| Min Length | minlength="N" |
text, email, url, tel, password, search, textarea | <input minlength="3"> |
| Max Length | maxlength="N" |
text, email, url, tel, password, search, textarea | <input maxlength="50"> |
| Min Value | min="N" |
number, range, date, time, datetime-local, month, week | <input type="number" min="0"> |
| Max Value | max="N" |
number, range, date, time, datetime-local, month, week | <input type="number" max="100"> |
| Step | step="N" |
number, range, date, time, datetime-local, month, week | <input type="number" step="0.01"> |
| Pattern | pattern="regex" |
text, email, url, tel, password, search | <input pattern="[0-9]{5}"> |
| Accept | accept="types" |
file | <input type="file" accept="image/*"> |
| Multiple | multiple |
email, file, select | <input type="email" multiple> |
Example: Input constraints in practice
<!-- Length constraints -->
<label for="username">Username (3-15 characters):</label>
<input type="text" id="username" name="username"
minlength="3" maxlength="15" required>
<!-- Number range with step -->
<label for="price">Price ($):</label>
<input type="number" id="price" name="price"
min="0" max="10000" step="0.01"
placeholder="0.00">
<!-- Age restriction -->
<label for="age">Age (must be 18+):</label>
<input type="number" id="age" name="age"
min="18" max="120" required>
<!-- Date range (current year only) -->
<label for="event-date">Event Date:</label>
<input type="date" id="event-date" name="event-date"
min="2024-01-01" max="2024-12-31" required>
<!-- Time with 15-minute intervals -->
<label for="appointment">Appointment Time:</label>
<input type="time" id="appointment" name="appointment"
min="09:00" max="17:00" step="900"> <!-- 900 seconds = 15 min -->
<!-- Percentage (0-100 with 0.1 precision) -->
<label for="discount">Discount (%):</label>
<input type="number" id="discount" name="discount"
min="0" max="100" step="0.1">
<!-- File upload (images only) -->
<label for="avatar">Profile Picture:</label>
<input type="file" id="avatar" name="avatar"
accept="image/png, image/jpeg, image/webp"
required>
<!-- Multiple file upload -->
<label for="documents">Upload Documents:</label>
<input type="file" id="documents" name="documents"
accept=".pdf,.doc,.docx"
multiple>
<!-- Multiple emails -->
<label for="recipients">Email Recipients:</label>
<input type="email" id="recipients" name="recipients"
multiple
placeholder="email1@example.com, email2@example.com">
<!-- Range slider with step -->
<label for="volume">Volume (0-100, steps of 5):</label>
<input type="range" id="volume" name="volume"
min="0" max="100" step="5" value="50">
<output for="volume">50</output>
<!-- No step constraint (any decimal) -->
<label for="precise">Precise Value:</label>
<input type="number" id="precise" name="precise"
step="any">
Note:
maxlength prevents typing beyond the limit, while minlength
only validates on submit. Use step="any" to allow any decimal value. For time inputs, step is in
seconds (900 = 15 minutes).
5. Form Submission and Method Handling (GET vs POST)
| Method | Use Case | Data in URL | Cacheable |
|---|---|---|---|
| GET | Search, filter, idempotent operations | Yes (query string) | Yes |
| POST | Create, update, delete, file uploads, sensitive data | No (request body) | No |
| DIALOG | Close dialog without submission | N/A | N/A |
Example: Form submission handling
<!-- GET form (search) -->
<form action="/search" method="GET">
<input type="search" name="q" placeholder="Search...">
<input type="checkbox" name="category" value="products"> Products
<input type="checkbox" name="category" value="articles"> Articles
<button type="submit">Search</button>
</form>
<!-- Submits to: /search?q=keyword&category=products&category=articles -->
<!-- POST form (user registration) -->
<form action="/register" method="POST" id="registerForm">
<input type="text" name="username" required>
<input type="email" name="email" required>
<input type="password" name="password" required>
<button type="submit">Register</button>
</form>
<!-- JavaScript: Intercept and handle submission -->
<script>
const form = document.getElementById('registerForm');
// Method 1: Prevent default and use Fetch API
form.addEventListener('submit', async (e) => {
e.preventDefault(); // Prevent traditional form submission
// Get form data
const formData = new FormData(form);
// Convert to JSON (if needed)
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
const result = await response.json();
console.log('Success:', result);
// Redirect or show success message
} else {
console.error('Error:', response.statusText);
}
} catch (error) {
console.error('Network error:', error);
}
});
// Method 2: FormData with all form fields
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
// Send as multipart/form-data (good for file uploads)
const response = await fetch('/register', {
method: 'POST',
body: formData, // Browser sets correct Content-Type
});
});
// Method 3: Programmatic submission
function submitForm() {
if (form.checkValidity()) {
// Triggers validation and submit event
form.requestSubmit();
} else {
// Show validation errors
form.reportValidity();
}
}
</script>
<!-- Multiple submit buttons with different actions -->
<form method="POST">
<input type="text" name="content" required>
<button type="submit" name="action" value="save">Save Draft</button>
<button type="submit" name="action" value="publish">Publish</button>
<button type="submit" name="action" value="delete" formnovalidate>Delete</button>
</form>
<!-- Override form attributes on button -->
<form action="/default" method="POST">
<input type="email" name="email" required>
<button type="submit">Submit to Default</button>
<button type="submit"
formaction="/alternative"
formmethod="GET"
formnovalidate>
Submit to Alternative (Skip Validation)
</button>
</form>
Warning:
form.submit() bypasses validation and doesn't fire the submit event. Use
form.requestSubmit() instead to trigger validation. Use formnovalidate on submit
buttons to skip validation for actions like "Save Draft" or "Delete".
6. Form Data Encoding (enctype) and File Uploads
| Encoding Type (enctype) | Value | Use Case | Content-Type Header |
|---|---|---|---|
| URL Encoded | application/x-www-form-urlencoded |
Standard forms (default) | application/x-www-form-urlencoded |
| Multipart | multipart/form-data |
File uploads | multipart/form-data; boundary=... |
| Plain Text | text/plain |
Debugging (not recommended) | text/plain |
Example: File uploads and form data encoding
<!-- File upload form -->
<form action="/upload" method="POST" enctype="multipart/form-data" id="uploadForm">
<label for="file">Select File:</label>
<input type="file" id="file" name="file" accept="image/*" required>
<label for="description">Description:</label>
<input type="text" id="description" name="description">
<button type="submit">Upload</button>
</form>
<!-- Multiple file upload with preview -->
<form id="multiUploadForm">
<input type="file" id="files" name="files" multiple accept="image/*">
<div id="preview"></div>
<button type="submit">Upload All</button>
</form>
<script>
// Single file upload
const uploadForm = document.getElementById('uploadForm');
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(uploadForm);
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData, // Browser automatically sets multipart/form-data
});
if (response.ok) {
const result = await response.json();
console.log('Upload successful:', result);
}
} catch (error) {
console.error('Upload failed:', error);
}
});
// Multiple files with preview
const filesInput = document.getElementById('files');
const preview = document.getElementById('preview');
filesInput.addEventListener('change', (e) => {
preview.innerHTML = ''; // Clear previous previews
const files = e.target.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Validate file
if (!file.type.startsWith('image/')) {
alert(`${file.name} is not an image`);
continue;
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
alert(`${file.name} is too large (max 5MB)`);
continue;
}
// Create preview
const reader = new FileReader();
reader.onload = (event) => {
const img = document.createElement('img');
img.src = event.target.result;
img.style.width = '100px';
img.style.margin = '5px';
preview.appendChild(img);
};
reader.readAsDataURL(file);
}
});
// Multi-file upload
const multiUploadForm = document.getElementById('multiUploadForm');
multiUploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData();
const files = filesInput.files;
// Add each file
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
// Add additional data
formData.append('userId', '12345');
formData.append('category', 'photos');
const response = await fetch('/upload-multiple', {
method: 'POST',
body: formData,
});
});
// Manual FormData construction
const manualFormData = new FormData();
// Add text fields
manualFormData.append('username', 'john_doe');
manualFormData.append('email', 'john@example.com');
// Add file (from input element)
const fileInput = document.getElementById('file');
manualFormData.append('avatar', fileInput.files[0]);
// Add file (blob/programmatically)
const blob = new Blob(['Hello, world!'], { type: 'text/plain' });
manualFormData.append('note', blob, 'note.txt');
// Get values
console.log(manualFormData.get('username')); // 'john_doe'
console.log(manualFormData.getAll('username')); // ['john_doe']
// Check if field exists
console.log(manualFormData.has('email')); // true
// Delete field
manualFormData.delete('note');
// Iterate over entries
for (const [key, value] of manualFormData.entries()) {
console.log(`${key}:`, value);
}
</script>
<!-- Camera capture (mobile) -->
<form>
<!-- Use front camera -->
<input type="file" accept="image/*" capture="user">
<!-- Use back camera -->
<input type="file" accept="image/*" capture="environment">
<!-- Video capture -->
<input type="file" accept="video/*" capture>
</form>
Note: Always set
enctype="multipart/form-data" for file uploads. When using
FormData with fetch(), don't set Content-Type header - browser adds it automatically with boundary. Use
capture attribute on mobile to directly access camera.
Section 8 Key Takeaways
- Use
:user-invalidor:not(:placeholder-shown):invalidto avoid styling empty fields as invalid - Always include
titlewithpatternfor user-friendly error messages - Use
setCustomValidity()for custom validation, but remember to clear it withsetCustomValidity('') - Use
form.requestSubmit()instead ofform.submit()to trigger validation maxlengthprevents typing,minlengthvalidates on submit- Set
enctype="multipart/form-data"for file uploads - Use
formnovalidateon buttons for actions that should skip validation (e.g., "Save Draft") - FormData automatically handles multipart encoding - don't manually set Content-Type when using fetch()