Form Handling & Validation
Accessing Form Elements in JavaScript
Before you can validate or process a form, you need to know how to access its elements. JavaScript provides multiple ways to reference forms and their inputs. The most reliable approach is using the document.forms collection, element IDs, or the elements property of a form object. Each method has its use case, and understanding all of them makes you a more versatile developer.
Example: Different Ways to Access Form Elements
<form id="signup-form" name="signup">
<input type="text" id="username" name="username">
<input type="email" id="email" name="email">
<input type="password" id="password" name="password">
<button type="submit">Sign Up</button>
</form>
<script>
// Method 1: By ID (most common and reliable)
const form = document.getElementById('signup-form');
// Method 2: Using document.forms collection
const formByName = document.forms['signup']; // by name attribute
const formByIndex = document.forms[0]; // by index
// Access individual inputs through the form.elements collection
const usernameInput = form.elements['username']; // by name attribute
const emailInput = form.elements['email'];
const passwordInput = form.elements['password'];
// Access by ID directly
const usernameById = document.getElementById('username');
// The elements property also supports index access
const firstInput = form.elements[0]; // first input in the form
// Get total number of form controls
console.log('Form has', form.elements.length, 'elements');
// Loop through all form elements
for (let i = 0; i < form.elements.length; i++) {
const el = form.elements[i];
console.log(el.name, el.type, el.value);
}
</script>
form.elements['name'] is preferred over document.getElementById() when working with forms because it scopes the lookup to that specific form. This prevents conflicts when a page contains multiple forms with similarly named fields.Form Submission: The submit Event
The submit event fires when a user submits a form, either by clicking a submit button or pressing Enter inside a text input. This event fires on the <form> element, not on the submit button. The most important thing to understand about form submission is that the browser will attempt to navigate to the form's action URL by default. To handle submission with JavaScript, you must call event.preventDefault() to stop this default behavior.
Example: Handling Form Submission
<form id="contact-form" action="/api/contact" method="POST">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<label for="message">Message:</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send</button>
</form>
<script>
const form = document.getElementById('contact-form');
form.addEventListener('submit', function(e) {
// Prevent the browser from navigating to the action URL
e.preventDefault();
// Now you can process the form data with JavaScript
const name = form.elements['name'].value;
const message = form.elements['message'].value;
console.log('Name:', name);
console.log('Message:', message);
// Send data via fetch API instead
fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, message: message })
})
.then(function(response) { return response.json(); })
.then(function(data) {
console.log('Success:', data);
form.reset(); // Clear the form after successful submission
})
.catch(function(error) {
console.error('Error:', error);
});
});
</script>
e.preventDefault() at the very beginning of your submit handler, before any validation logic. If your validation code throws an error before reaching preventDefault(), the form will submit normally and navigate away from the page, losing any error messages you intended to display.The FormData API
The FormData API provides a convenient way to capture all form values at once without manually reading each input. It automatically handles different input types including file uploads, checkboxes, and select elements. FormData objects can be sent directly with the Fetch API or XMLHttpRequest.
Example: Using FormData to Collect Form Values
<form id="profile-form">
<input type="text" name="firstName" value="John">
<input type="text" name="lastName" value="Doe">
<input type="email" name="email" value="john@example.com">
<select name="role">
<option value="developer" selected>Developer</option>
<option value="designer">Designer</option>
</select>
<input type="file" name="avatar">
<button type="submit">Save Profile</button>
</form>
<script>
document.getElementById('profile-form').addEventListener('submit', function(e) {
e.preventDefault();
// Create FormData from the form element
const formData = new FormData(this);
// Read individual values
console.log('First Name:', formData.get('firstName'));
console.log('Email:', formData.get('email'));
// Check if a field exists
console.log('Has role:', formData.has('role'));
// Iterate over all entries
for (const [key, value] of formData.entries()) {
console.log(key + ': ' + value);
}
// Convert to a plain object (excluding files)
const dataObject = Object.fromEntries(formData.entries());
console.log('Form data as object:', dataObject);
// Append additional data not in the form
formData.append('submittedAt', new Date().toISOString());
// Send with fetch -- FormData sets Content-Type automatically
fetch('/api/profile', {
method: 'POST',
body: formData // Do NOT set Content-Type header manually
});
});
</script>
FormData with fetch(), do not manually set the Content-Type header. The browser will automatically set it to multipart/form-data with the correct boundary string needed for file uploads. Setting it manually will break the request.Input Types and Their Values
Different HTML input types return their values in different formats. Understanding these differences is essential for proper form handling and validation.
Example: Reading Values from Different Input Types
<form id="demo-form">
<input type="text" name="username" value="johndoe">
<input type="number" name="age" value="25">
<input type="range" name="volume" min="0" max="100" value="75">
<input type="date" name="birthday" value="1998-05-15">
<input type="time" name="alarm" value="07:30">
<input type="color" name="favorite" value="#3498db">
<input type="url" name="website" value="https://example.com">
<input type="hidden" name="userId" value="12345">
<textarea name="bio">Hello world</textarea>
</form>
<script>
const form = document.getElementById('demo-form');
// All .value properties return strings, even for number and range
const age = form.elements['age'].value; // "25" (string!)
const volume = form.elements['volume'].value; // "75" (string!)
// Convert to numbers when needed
const ageNumber = parseInt(form.elements['age'].value, 10); // 25
const volumeNumber = parseFloat(form.elements['volume'].value); // 75
// Alternative: use valueAsNumber for number, range, and date inputs
const ageFromProp = form.elements['age'].valueAsNumber; // 25
const dateFromProp = form.elements['birthday'].valueAsDate; // Date object
// Textarea value works the same as text input
const bio = form.elements['bio'].value; // "Hello world"
// Hidden inputs are accessible just like visible ones
const userId = form.elements['userId'].value; // "12345"
console.log(typeof age); // "string"
console.log(typeof ageNumber); // "number"
</script>
Checkboxes and Radio Buttons
Checkboxes and radio buttons require special handling because their value does not change -- only their checked state does. You must use the checked property (a boolean) rather than the value property to determine the user's selection.
Example: Working with Checkboxes and Radio Buttons
<form id="preferences-form">
<fieldset>
<legend>Notifications</legend>
<label>
<input type="checkbox" name="notifications" value="email" checked> Email
</label>
<label>
<input type="checkbox" name="notifications" value="sms"> SMS
</label>
<label>
<input type="checkbox" name="notifications" value="push" checked> Push
</label>
</fieldset>
<fieldset>
<legend>Theme</legend>
<label>
<input type="radio" name="theme" value="light" checked> Light
</label>
<label>
<input type="radio" name="theme" value="dark"> Dark
</label>
<label>
<input type="radio" name="theme" value="auto"> Auto
</label>
</fieldset>
<label>
<input type="checkbox" name="terms" value="accepted"> I agree to the terms
</label>
<button type="submit">Save</button>
</form>
<script>
document.getElementById('preferences-form').addEventListener('submit', function(e) {
e.preventDefault();
// Single checkbox: check the boolean .checked property
const termsAccepted = form.elements['terms'].checked; // true or false
console.log('Terms accepted:', termsAccepted);
// Multiple checkboxes with the same name: get all checked values
const checkboxes = form.querySelectorAll('input[name="notifications"]:checked');
const selectedNotifications = Array.from(checkboxes).map(function(cb) {
return cb.value;
});
console.log('Notifications:', selectedNotifications); // ["email", "push"]
// Radio buttons: find the selected one
const selectedTheme = form.elements['theme'].value; // value of checked radio
console.log('Theme:', selectedTheme); // "light"
// Alternative for radio: use querySelector
const checkedRadio = form.querySelector('input[name="theme"]:checked');
console.log('Theme (alt):', checkedRadio ? checkedRadio.value : 'none');
});
// Listen for changes on individual checkboxes
const termsCheckbox = document.querySelector('input[name="terms"]');
termsCheckbox.addEventListener('change', function() {
console.log('Terms checkbox changed to:', this.checked);
});
</script>
Select Elements
Select elements can be single-selection or multiple-selection. Single selects return a string value, while multiple selects require iterating through the selected options.
Example: Working with Select Elements
<form id="order-form">
<label for="country">Country:</label>
<select id="country" name="country">
<option value="">-- Select --</option>
<option value="us">United States</option>
<option value="uk" selected>United Kingdom</option>
<option value="ca">Canada</option>
</select>
<label for="languages">Languages:</label>
<select id="languages" name="languages" multiple size="4">
<option value="js" selected>JavaScript</option>
<option value="py">Python</option>
<option value="rb" selected>Ruby</option>
<option value="go">Go</option>
</select>
</form>
<script>
const form = document.getElementById('order-form');
const countrySelect = form.elements['country'];
const languagesSelect = form.elements['languages'];
// Single select: .value returns the selected option's value
console.log('Country:', countrySelect.value); // "uk"
// Get the display text of the selected option
const selectedIndex = countrySelect.selectedIndex;
const selectedText = countrySelect.options[selectedIndex].text;
console.log('Country name:', selectedText); // "United Kingdom"
// Multiple select: iterate through options to find selected ones
const selectedLanguages = Array.from(languagesSelect.selectedOptions).map(function(opt) {
return opt.value;
});
console.log('Languages:', selectedLanguages); // ["js", "rb"]
// Listen for changes
countrySelect.addEventListener('change', function() {
console.log('Country changed to:', this.value);
console.log('Display text:', this.options[this.selectedIndex].text);
});
// Dynamically add options
const newOption = document.createElement('option');
newOption.value = 'de';
newOption.textContent = 'Germany';
countrySelect.appendChild(newOption);
// Programmatically set value
countrySelect.value = 'ca'; // Selects Canada
</script>
Real-Time Validation with input and change Events
Users expect immediate feedback as they fill out forms. The input event fires every time the value changes (on each keystroke for text inputs), while the change event fires when the user finishes editing and moves away from the field. Using these events together gives you fine-grained control over when to validate and display feedback.
Example: Real-Time Validation Feedback
<form id="realtime-form">
<div class="field-group">
<label for="rt-username">Username:</label>
<input type="text" id="rt-username" name="username"
minlength="3" maxlength="20" required>
<span class="error-message" id="username-error"></span>
<span class="char-count" id="username-count">0/20</span>
</div>
<div class="field-group">
<label for="rt-email">Email:</label>
<input type="email" id="rt-email" name="email" required>
<span class="error-message" id="email-error"></span>
</div>
<div class="field-group">
<label for="rt-password">Password:</label>
<input type="password" id="rt-password" name="password"
minlength="8" required>
<div class="password-strength" id="password-strength"></div>
<span class="error-message" id="password-error"></span>
</div>
</form>
<script>
const usernameInput = document.getElementById('rt-username');
const emailInput = document.getElementById('rt-email');
const passwordInput = document.getElementById('rt-password');
// input event: fires on EVERY keystroke (real-time)
usernameInput.addEventListener('input', function() {
const count = this.value.length;
document.getElementById('username-count').textContent = count + '/20';
// Visual feedback based on length
if (count > 0 && count < 3) {
showError('username-error', 'Username must be at least 3 characters');
this.classList.add('invalid');
this.classList.remove('valid');
} else if (count >= 3) {
clearError('username-error');
this.classList.add('valid');
this.classList.remove('invalid');
} else {
clearError('username-error');
this.classList.remove('valid', 'invalid');
}
});
// change event: fires when user leaves the field (on blur)
emailInput.addEventListener('change', function() {
if (this.value && !isValidEmail(this.value)) {
showError('email-error', 'Please enter a valid email address');
this.classList.add('invalid');
} else if (this.value) {
clearError('email-error');
this.classList.add('valid');
this.classList.remove('invalid');
}
});
// Combine input event for password strength meter
passwordInput.addEventListener('input', function() {
const strength = calculatePasswordStrength(this.value);
const strengthEl = document.getElementById('password-strength');
strengthEl.textContent = 'Strength: ' + strength.label;
strengthEl.className = 'password-strength ' + strength.level;
});
function calculatePasswordStrength(password) {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 1) return { label: 'Weak', level: 'weak' };
if (score <= 3) return { label: 'Medium', level: 'medium' };
return { label: 'Strong', level: 'strong' };
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function showError(elementId, message) {
document.getElementById(elementId).textContent = message;
}
function clearError(elementId) {
document.getElementById(elementId).textContent = '';
}
</script>
input event for character counters, search-as-you-type, and password strength indicators where immediate feedback improves the experience. Use the change event for validation that should not interrupt the user while they are still typing, such as email format checks or API-based uniqueness checks.The Constraint Validation API
Modern browsers provide a built-in Constraint Validation API that works with HTML5 validation attributes like required, minlength, pattern, and type. This API gives you programmatic access to the validation state of each input and the ability to customize error messages. The key properties and methods are validity, checkValidity(), setCustomValidity(), and reportValidity().
The validity Property
Every form input has a validity property that returns a ValidityState object. This object contains boolean flags for every type of validation error. Checking these flags tells you exactly what is wrong with the input.
Example: Inspecting the ValidityState Object
<form id="validity-demo">
<input type="email" id="demo-email" required minlength="5" maxlength="50">
<input type="number" id="demo-age" min="18" max="120" step="1">
<input type="text" id="demo-zip" pattern="[0-9]{5}" title="5-digit ZIP code">
<button type="submit">Check</button>
</form>
<script>
const emailField = document.getElementById('demo-email');
// The validity property contains these boolean flags:
// valueMissing -- fails 'required' constraint
// typeMismatch -- fails 'type' constraint (e.g., not a valid email)
// patternMismatch -- fails 'pattern' constraint
// tooLong -- exceeds 'maxlength'
// tooShort -- below 'minlength'
// rangeOverflow -- exceeds 'max' for number/date
// rangeUnderflow -- below 'min' for number/date
// stepMismatch -- does not match 'step' value
// badInput -- browser cannot convert the input
// customError -- setCustomValidity() has been called
// valid -- true if ALL constraints pass
emailField.addEventListener('input', function() {
const v = this.validity;
console.log('Valid:', v.valid);
console.log('Value Missing:', v.valueMissing);
console.log('Type Mismatch:', v.typeMismatch);
console.log('Too Short:', v.tooShort);
// Use validationMessage for the browser's default error message
if (!v.valid) {
console.log('Error:', this.validationMessage);
}
});
</script>
checkValidity() and reportValidity()
The checkValidity() method returns true if the element satisfies all constraints, or false otherwise. It also fires an invalid event on elements that fail validation. The reportValidity() method does the same but additionally displays the browser's built-in error tooltip to the user.
Example: Using checkValidity and reportValidity
<form id="check-form">
<input type="text" id="check-name" required minlength="2">
<input type="email" id="check-email" required>
<button type="button" id="validate-btn">Validate</button>
<button type="submit">Submit</button>
</form>
<script>
const checkForm = document.getElementById('check-form');
// checkValidity on individual field -- returns boolean silently
document.getElementById('validate-btn').addEventListener('click', function() {
const nameField = document.getElementById('check-name');
const emailField = document.getElementById('check-email');
// Check individual fields
const nameValid = nameField.checkValidity();
const emailValid = emailField.checkValidity();
console.log('Name valid:', nameValid, 'Email valid:', emailValid);
// Check entire form at once
const formValid = checkForm.checkValidity();
console.log('Form valid:', formValid);
// reportValidity shows the browser tooltip on first invalid field
if (!formValid) {
checkForm.reportValidity(); // Shows tooltip on first invalid input
}
});
// Listen for the invalid event (fired by checkValidity on failing elements)
document.getElementById('check-name').addEventListener('invalid', function(e) {
console.log('Name field is invalid:', this.validationMessage);
// Prevent the default browser tooltip if you want custom UI
// e.preventDefault();
});
checkForm.addEventListener('submit', function(e) {
e.preventDefault();
if (this.checkValidity()) {
console.log('Form is valid -- submitting');
} else {
console.log('Form has errors');
this.reportValidity();
}
});
</script>
setCustomValidity()
The setCustomValidity() method allows you to define custom error messages that replace the browser's default messages. When you set a non-empty string, the field is marked as invalid. You must set it back to an empty string to clear the custom error and allow the field to be valid again.
Example: Custom Validation Messages
<form id="custom-form">
<label for="custom-pw">Password:</label>
<input type="password" id="custom-pw" name="password" required minlength="8">
<label for="custom-confirm">Confirm Password:</label>
<input type="password" id="custom-confirm" name="confirmPassword" required>
<button type="submit">Register</button>
</form>
<script>
const password = document.getElementById('custom-pw');
const confirm = document.getElementById('custom-confirm');
// Custom message for the password field
password.addEventListener('input', function() {
if (this.value.length > 0 && this.value.length < 8) {
this.setCustomValidity('Password must be at least 8 characters long.');
} else if (this.value && !/[A-Z]/.test(this.value)) {
this.setCustomValidity('Password must contain at least one uppercase letter.');
} else if (this.value && !/[0-9]/.test(this.value)) {
this.setCustomValidity('Password must contain at least one number.');
} else {
this.setCustomValidity(''); // Clear the error -- field is valid
}
});
// Password confirmation matching
confirm.addEventListener('input', function() {
if (this.value !== password.value) {
this.setCustomValidity('Passwords do not match.');
} else {
this.setCustomValidity('');
}
});
// Also re-check confirm when password changes
password.addEventListener('input', function() {
if (confirm.value && confirm.value !== this.value) {
confirm.setCustomValidity('Passwords do not match.');
} else {
confirm.setCustomValidity('');
}
});
document.getElementById('custom-form').addEventListener('submit', function(e) {
e.preventDefault();
if (this.checkValidity()) {
console.log('Registration successful');
} else {
this.reportValidity();
}
});
</script>
setCustomValidity('') with an empty string to clear a custom error. If you forget this step, the field will remain permanently invalid even if the user corrects their input. Always clear the custom validity before re-checking in your event handler.Regex Validation
Regular expressions provide powerful pattern matching for form validation. While HTML5 has a pattern attribute that accepts regex, JavaScript gives you more flexibility for complex validation rules and custom error messages.
Example: Regex Patterns for Common Validations
<form id="regex-form">
<input type="text" id="phone" name="phone" placeholder="(555) 123-4567">
<input type="text" id="zipcode" name="zipcode" placeholder="12345 or 12345-6789">
<input type="text" id="website" name="website" placeholder="https://example.com">
<input type="text" id="slug" name="slug" placeholder="my-page-slug">
<button type="submit">Validate</button>
</form>
<script>
const validationRules = {
phone: {
// Matches: (555) 123-4567, 555-123-4567, 5551234567
pattern: /^(\(\d{3}\)\s?|\d{3}[-.]?)\d{3}[-.]?\d{4}$/,
message: 'Enter a valid US phone number'
},
zipcode: {
// Matches: 12345 or 12345-6789
pattern: /^\d{5}(-\d{4})?$/,
message: 'Enter a valid ZIP code (12345 or 12345-6789)'
},
website: {
// Matches: http:// or https:// followed by domain
pattern: /^https?:\/\/[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}(\/\S*)?$/,
message: 'Enter a valid URL starting with http:// or https://'
},
slug: {
// Matches: lowercase letters, numbers, and hyphens
pattern: /^[a-z0-9]+(-[a-z0-9]+)*$/,
message: 'Only lowercase letters, numbers, and hyphens allowed'
}
};
document.getElementById('regex-form').addEventListener('submit', function(e) {
e.preventDefault();
let isValid = true;
Object.keys(validationRules).forEach(function(fieldName) {
const input = document.getElementById(fieldName);
const rule = validationRules[fieldName];
if (input.value && !rule.pattern.test(input.value)) {
input.setCustomValidity(rule.message);
isValid = false;
} else {
input.setCustomValidity('');
}
});
if (!isValid) {
this.reportValidity();
} else {
console.log('All fields valid');
}
});
// Clear custom validity on input so user can fix errors
document.querySelectorAll('#regex-form input').forEach(function(input) {
input.addEventListener('input', function() {
this.setCustomValidity('');
});
});
</script>
Building a Complete Registration Form with Validation
Now let us bring everything together into a complete, production-quality registration form. This example combines real-time validation, the Constraint Validation API, regex patterns, custom error messages, and accessible error display. It demonstrates the full pattern you would use in a real application.
Example: Complete Registration Form
<style>
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 4px; font-weight: bold; }
.form-group input, .form-group select { width: 100%; padding: 8px; border: 2px solid #ccc; }
.form-group input.valid { border-color: #27ae60; }
.form-group input.invalid { border-color: #e74c3c; }
.error-text { color: #e74c3c; font-size: 14px; margin-top: 4px; display: block; }
.error-text:empty { display: none; }
.form-success { background: #d4edda; color: #155724; padding: 16px; display: none; }
</style>
<div id="success-message" class="form-success" role="alert">
Registration successful! Welcome aboard.
</div>
<form id="registration-form" novalidate>
<div class="form-group">
<label for="reg-name">Full Name *</label>
<input type="text" id="reg-name" name="fullName"
required minlength="2" maxlength="100"
autocomplete="name"
aria-describedby="name-error">
<span class="error-text" id="name-error" role="alert"></span>
</div>
<div class="form-group">
<label for="reg-email">Email Address *</label>
<input type="email" id="reg-email" name="email"
required autocomplete="email"
aria-describedby="email-error">
<span class="error-text" id="email-error" role="alert"></span>
</div>
<div class="form-group">
<label for="reg-phone">Phone Number</label>
<input type="tel" id="reg-phone" name="phone"
autocomplete="tel"
aria-describedby="phone-error">
<span class="error-text" id="phone-error" role="alert"></span>
</div>
<div class="form-group">
<label for="reg-password">Password *</label>
<input type="password" id="reg-password" name="password"
required minlength="8" autocomplete="new-password"
aria-describedby="password-error password-hint">
<small id="password-hint">Minimum 8 characters with uppercase, number, and symbol.</small>
<span class="error-text" id="password-error" role="alert"></span>
</div>
<div class="form-group">
<label for="reg-confirm">Confirm Password *</label>
<input type="password" id="reg-confirm" name="confirmPassword"
required autocomplete="new-password"
aria-describedby="confirm-error">
<span class="error-text" id="confirm-error" role="alert"></span>
</div>
<div class="form-group">
<label for="reg-country">Country *</label>
<select id="reg-country" name="country" required
aria-describedby="country-error">
<option value="">-- Select your country --</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
</select>
<span class="error-text" id="country-error" role="alert"></span>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="reg-terms" name="terms" required
aria-describedby="terms-error">
I agree to the Terms of Service *
</label>
<span class="error-text" id="terms-error" role="alert"></span>
</div>
<button type="submit" id="submit-btn">Create Account</button>
</form>
<script>
const regForm = document.getElementById('registration-form');
// Validation rules for each field
const validators = {
fullName: function(value) {
if (!value.trim()) return 'Full name is required.';
if (value.trim().length < 2) return 'Name must be at least 2 characters.';
if (!/^[a-zA-Z\s'-]+$/.test(value)) return 'Name can only contain letters, spaces, hyphens, and apostrophes.';
return '';
},
email: function(value) {
if (!value.trim()) return 'Email address is required.';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Please enter a valid email address.';
return '';
},
phone: function(value) {
if (!value) return ''; // Optional field
if (!/^[\d\s()+-]{7,20}$/.test(value)) return 'Please enter a valid phone number.';
return '';
},
password: function(value) {
if (!value) return 'Password is required.';
if (value.length < 8) return 'Password must be at least 8 characters.';
if (!/[A-Z]/.test(value)) return 'Password must contain at least one uppercase letter.';
if (!/[0-9]/.test(value)) return 'Password must contain at least one number.';
if (!/[^A-Za-z0-9]/.test(value)) return 'Password must contain at least one special character.';
return '';
},
confirmPassword: function(value) {
if (!value) return 'Please confirm your password.';
const pw = document.getElementById('reg-password').value;
if (value !== pw) return 'Passwords do not match.';
return '';
},
country: function(value) {
if (!value) return 'Please select your country.';
return '';
},
terms: function(value, element) {
if (!element.checked) return 'You must agree to the Terms of Service.';
return '';
}
};
// Map field names to error element IDs
const errorMap = {
fullName: 'name-error',
email: 'email-error',
phone: 'phone-error',
password: 'password-error',
confirmPassword: 'confirm-error',
country: 'country-error',
terms: 'terms-error'
};
// Map field names to input IDs
const inputMap = {
fullName: 'reg-name',
email: 'reg-email',
phone: 'reg-phone',
password: 'reg-password',
confirmPassword: 'reg-confirm',
country: 'reg-country',
terms: 'reg-terms'
};
function validateField(fieldName) {
const input = document.getElementById(inputMap[fieldName]);
const errorEl = document.getElementById(errorMap[fieldName]);
const errorMsg = validators[fieldName](input.value, input);
errorEl.textContent = errorMsg;
if (errorMsg) {
input.classList.add('invalid');
input.classList.remove('valid');
input.setAttribute('aria-invalid', 'true');
} else if (input.value || input.checked) {
input.classList.add('valid');
input.classList.remove('invalid');
input.setAttribute('aria-invalid', 'false');
} else {
input.classList.remove('valid', 'invalid');
input.removeAttribute('aria-invalid');
}
return errorMsg === '';
}
// Attach real-time validation to each field
Object.keys(inputMap).forEach(function(fieldName) {
const input = document.getElementById(inputMap[fieldName]);
const eventType = (input.type === 'checkbox' || input.tagName === 'SELECT')
? 'change' : 'input';
input.addEventListener(eventType, function() {
validateField(fieldName);
// Re-validate confirm when password changes
if (fieldName === 'password') {
const confirmVal = document.getElementById('reg-confirm').value;
if (confirmVal) validateField('confirmPassword');
}
});
// Also validate on blur for fields the user skips
if (eventType === 'input') {
input.addEventListener('blur', function() {
if (this.value) validateField(fieldName);
});
}
});
// Handle form submission
regForm.addEventListener('submit', function(e) {
e.preventDefault();
// Validate all fields
let formIsValid = true;
let firstInvalidField = null;
Object.keys(validators).forEach(function(fieldName) {
const isValid = validateField(fieldName);
if (!isValid && !firstInvalidField) {
firstInvalidField = document.getElementById(inputMap[fieldName]);
}
if (!isValid) formIsValid = false;
});
if (!formIsValid) {
// Focus the first invalid field for accessibility
if (firstInvalidField) firstInvalidField.focus();
return;
}
// Collect all form data
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
delete data.confirmPassword; // Do not send confirmation field
delete data.terms; // Do not send checkbox value
console.log('Submitting:', data);
// Show success message
regForm.style.display = 'none';
document.getElementById('success-message').style.display = 'block';
});
</script>
Accessible Error Messages
Form validation must be accessible to all users, including those using screen readers. There are several key techniques that make your error messages accessible and help users understand and fix validation issues efficiently.
- Use
aria-describedby-- Link each input to its error message element usingaria-describedby. When the input receives focus, the screen reader will announce the error message along with the input label. - Use
aria-invalid-- Setaria-invalid="true"on inputs that fail validation. This tells screen readers the field contains an error. Remove it or set it to"false"when the error is corrected. - Use
role="alert"-- Addingrole="alert"to error message elements causes screen readers to announce the message immediately when it changes, without the user needing to navigate to it. - Focus management -- When form submission fails, move focus to the first invalid field. This orients the user and lets them start correcting errors immediately.
- Visible error messages -- Never rely solely on color to indicate errors. Always include a text message that describes the problem and how to fix it.
Example: Accessible Error Message Pattern
<!-- The complete accessible pattern for a form field -->
<div class="form-group">
<label for="acc-email">
Email Address
<span aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input type="email" id="acc-email" name="email"
required
aria-required="true"
aria-invalid="false"
aria-describedby="email-help email-err">
<small id="email-help">We will never share your email.</small>
<!-- role="alert" makes screen readers announce changes immediately -->
<span id="email-err" class="error-text" role="alert"></span>
</div>
<script>
function setFieldError(inputId, errorId, message) {
const input = document.getElementById(inputId);
const errorEl = document.getElementById(errorId);
if (message) {
errorEl.textContent = message;
input.setAttribute('aria-invalid', 'true');
input.classList.add('invalid');
} else {
errorEl.textContent = '';
input.setAttribute('aria-invalid', 'false');
input.classList.remove('invalid');
}
}
// When setting error, screen reader announces it immediately
// because the error element has role="alert"
setFieldError('acc-email', 'email-err', 'Please enter a valid email address.');
// When clearing error
setFieldError('acc-email', 'email-err', '');
</script>
novalidate attribute on the <form> element disables the browser's built-in validation tooltips, giving you full control over the error display. However, the Constraint Validation API (validity, checkValidity()) still works -- only the automatic tooltip display is suppressed. Use novalidate when you want custom-styled error messages instead of browser defaults.Practice Exercise
Build a multi-step registration form with three steps. Step 1 collects personal info (full name, email, phone). Step 2 collects account info (username with real-time uniqueness check against a local array, password with strength meter, and confirm password). Step 3 collects preferences (country select, notification checkboxes for email, SMS, and push, and a terms agreement checkbox). Each step should be a <fieldset> that shows and hides as the user progresses. Include "Next" and "Back" buttons to navigate between steps. Validate each step before allowing the user to proceed to the next one. Use the Constraint Validation API with setCustomValidity() for password matching and custom rules. Use regex to validate the phone number format and username format (lowercase letters and numbers only). Display all errors inline with aria-describedby, aria-invalid, and role="alert" for screen reader accessibility. Add a character counter for the username field that updates on every keystroke. On the final step, show a summary of all entered data before the user confirms submission. Use the FormData API to collect all values and log them to the console on submission.