HTML Form Validation
HTML5 provides built-in form validation through attributes and the Constraint Validation API. Before HTML5, developers relied entirely on JavaScript for validation. Today, you can enforce required fields, format constraints, and range limits using only HTML attributes — with the browser handling the user interface and error messages natively.
Client-side validation improves user experience by providing instant feedback without a server round-trip. However, it is never a substitute for server-side validation — client-side checks are easily bypassed. Always validate on both sides of the network.
HTML5 validation attributes let you specify constraints declaratively, without JavaScript. The browser automatically checks these constraints on form submission and displays native error tooltips. Each attribute targets a specific type of validation.
| Attribute | Applies To | Validation Rule |
|---|---|---|
| required | Most input types, textarea, select | Field must have a non-empty value |
| minlength | text, email, url, tel, search, password, textarea | Value must be at least N characters |
| maxlength | text, email, url, tel, search, password, textarea | Value must not exceed N characters |
| min | number, date, range, datetime-local, month, week, time | Value must be at least N (numeric or date) |
| max | number, date, range, datetime-local, month, week, time | Value must not exceed N (numeric or date) |
| pattern | text, tel, email, url, search, password | Value must match the regex pattern |
| step | number, date, range, datetime-local, month, week, time | Value must be a multiple of step from min |
| type | email, url, number | Value must match the type's format (e.g., valid email) |
| multiple | email, file | Comma-separated email values or multiple files |
| 1 | <!-- Required fields --> |
| 2 | <form> |
| 3 | <label for="name">Name <span class="required">*</span></label> |
| 4 | <input type="text" id="name" name="name" required /> |
| 5 | |
| 6 | <label for="email">Email <span class="required">*</span></label> |
| 7 | <input type="email" id="email" name="email" required /> |
| 8 | |
| 9 | <button type="submit">Submit</button> |
| 10 | </form> |
| 11 | |
| 12 | <!-- Character length constraints --> |
| 13 | <input |
| 14 | type="text" |
| 15 | id="username" |
| 16 | name="username" |
| 17 | minlength="3" |
| 18 | maxlength="20" |
| 19 | required |
| 20 | placeholder="3-20 characters" |
| 21 | /> |
| 22 | |
| 23 | <!-- Numeric range constraints --> |
| 24 | <label for="age">Age</label> |
| 25 | <input |
| 26 | type="number" |
| 27 | id="age" |
| 28 | name="age" |
| 29 | min="18" |
| 30 | max="120" |
| 31 | step="1" |
| 32 | required |
| 33 | /> |
| 34 | |
| 35 | <!-- Date range constraints --> |
| 36 | <label for="appointment">Appointment Date</label> |
| 37 | <input |
| 38 | type="date" |
| 39 | id="appointment" |
| 40 | name="appointment" |
| 41 | min="2026-07-01" |
| 42 | max="2026-12-31" |
| 43 | required |
| 44 | /> |
| 45 | |
| 46 | <!-- Pattern validation --> |
| 47 | <label for="postcode">Postal Code (UK format)</label> |
| 48 | <input |
| 49 | type="text" |
| 50 | id="postcode" |
| 51 | name="postcode" |
| 52 | pattern="[A-Z]{1,2}[0-9R][0-9A-Z]? [0-9][ABD-HJLNP-UW-Z]{2}" |
| 53 | title="UK postcode format e.g. SW1A 1AA" |
| 54 | placeholder="SW1A 1AA" |
| 55 | /> |
| 56 | |
| 57 | <!-- Type-based validation --> |
| 58 | <label for="website">Website</label> |
| 59 | <input |
| 60 | type="url" |
| 61 | id="website" |
| 62 | name="website" |
| 63 | placeholder="https://example.com" |
| 64 | required |
| 65 | /> |
| 66 | |
| 67 | <!-- Multiple email addresses --> |
| 68 | <label for="team-emails">Team Emails (comma-separated)</label> |
| 69 | <input |
| 70 | type="email" |
| 71 | id="team-emails" |
| 72 | name="team-emails" |
| 73 | multiple |
| 74 | placeholder="alice@example.com, bob@example.com" |
| 75 | /> |
best practice
The Constraint Validation API gives JavaScript access to the browser's validation engine. Every form element exposes a validity object with boolean properties for each validation constraint. The checkValidity() method triggers validation, and setCustomValidity() lets you set custom error messages.
| Property | Type | Description |
|---|---|---|
| validity.valueMissing | boolean | Element has required attribute but no value |
| validity.typeMismatch | boolean | Value does not match the type (email, url) |
| validity.patternMismatch | boolean | Value does not match the pattern attribute |
| validity.tooLong | boolean | Value exceeds maxlength (only if user edits) |
| validity.tooShort | boolean | Value is shorter than minlength |
| validity.rangeUnderflow | boolean | Value is less than min |
| validity.rangeOverflow | boolean | Value is greater than max |
| validity.stepMismatch | boolean | Value does not match the step increment |
| validity.badInput | boolean | Browser cannot parse the value (e.g., "abc" in number) |
| validity.customError | boolean | Custom validity message has been set |
| validity.valid | boolean | All constraints pass (no errors) |
| checkValidity() | method | Returns true if valid, triggers validation UI if invalid |
| reportValidity() | method | Like checkValidity() but also shows the browser's error bubble |
| setCustomValidity(msg) | method | Sets a custom error message (empty string = no error) |
| validationMessage | property | Returns the current validation message string |
| willValidate | property | True if the element will be validated on submit |
| 1 | <form id="signupForm" novalidate> |
| 2 | <label for="fullname">Full Name</label> |
| 3 | <input |
| 4 | type="text" |
| 5 | id="fullname" |
| 6 | name="fullname" |
| 7 | required |
| 8 | minlength="2" |
| 9 | maxlength="100" |
| 10 | pattern="[A-Za-z\s]+" |
| 11 | title="Only letters and spaces" |
| 12 | /> |
| 13 | |
| 14 | <label for="age">Age</label> |
| 15 | <input |
| 16 | type="number" |
| 17 | id="age" |
| 18 | name="age" |
| 19 | required |
| 20 | min="18" |
| 21 | max="120" |
| 22 | step="1" |
| 23 | /> |
| 24 | |
| 25 | <label for="email">Email</label> |
| 26 | <input |
| 27 | type="email" |
| 28 | id="email" |
| 29 | name="email" |
| 30 | required |
| 31 | /> |
| 32 | |
| 33 | <button type="submit">Submit</button> |
| 34 | </form> |
| 35 | |
| 36 | <script> |
| 37 | const form = document.getElementById('signupForm'); |
| 38 | |
| 39 | form.addEventListener('submit', (e) => { |
| 40 | e.preventDefault(); |
| 41 | |
| 42 | // Check each field using the ValidityState API |
| 43 | const name = document.getElementById('fullname'); |
| 44 | const age = document.getElementById('age'); |
| 45 | const email = document.getElementById('email'); |
| 46 | |
| 47 | let isValid = true; |
| 48 | |
| 49 | // Validate name manually |
| 50 | const nameState = name.validity; |
| 51 | if (nameState.valueMissing) { |
| 52 | name.setCustomValidity('Please enter your full name'); |
| 53 | isValid = false; |
| 54 | } else if (nameState.patternMismatch) { |
| 55 | name.setCustomValidity('Only letters and spaces allowed'); |
| 56 | isValid = false; |
| 57 | } else if (nameState.tooShort) { |
| 58 | name.setCustomValidity('Name must be at least 2 characters'); |
| 59 | isValid = false; |
| 60 | } else { |
| 61 | name.setCustomValidity(''); |
| 62 | } |
| 63 | |
| 64 | // Validate age |
| 65 | const ageState = age.validity; |
| 66 | if (ageState.valueMissing) { |
| 67 | age.setCustomValidity('Please enter your age'); |
| 68 | isValid = false; |
| 69 | } else if (ageState.rangeUnderflow) { |
| 70 | age.setCustomValidity('You must be at least 18 years old'); |
| 71 | isValid = false; |
| 72 | } else if (ageState.rangeOverflow) { |
| 73 | age.setCustomValidity('Age cannot exceed 120'); |
| 74 | isValid = false; |
| 75 | } else { |
| 76 | age.setCustomValidity(''); |
| 77 | } |
| 78 | |
| 79 | // Validate email |
| 80 | const emailState = email.validity; |
| 81 | if (emailState.valueMissing) { |
| 82 | email.setCustomValidity('Please enter your email'); |
| 83 | isValid = false; |
| 84 | } else if (emailState.typeMismatch) { |
| 85 | email.setCustomValidity('Please enter a valid email address'); |
| 86 | isValid = false; |
| 87 | } else { |
| 88 | email.setCustomValidity(''); |
| 89 | } |
| 90 | |
| 91 | if (isValid) { |
| 92 | // Report any remaining validity issues |
| 93 | if (form.checkValidity()) { |
| 94 | alert('Form is valid! Submitting...'); |
| 95 | // form.submit(); |
| 96 | } |
| 97 | } else { |
| 98 | // Show the first error |
| 99 | form.reportValidity(); |
| 100 | } |
| 101 | }); |
| 102 | |
| 103 | // Clear custom validity on input |
| 104 | form.querySelectorAll('input').forEach((input) => { |
| 105 | input.addEventListener('input', () => { |
| 106 | input.setCustomValidity(''); |
| 107 | }); |
| 108 | }); |
| 109 | </script> |
pro tip
The browser's default validation messages are generic and vary by locale. Use the Constraint Validation API's setCustomValidity() method to provide user-friendly, context-specific messages. You can also use the validationMessage property to read the current message or checkValidity() with oninvalid handler for inline customization.
| 1 | <!-- Approach 1: oninvalid handler (declarative) --> |
| 2 | <input |
| 3 | type="text" |
| 4 | id="zip" |
| 5 | name="zip" |
| 6 | pattern="[0-9]{5}" |
| 7 | required |
| 8 | oninvalid="this.setCustomValidity('Please enter a 5-digit ZIP code (e.g., 12345)')" |
| 9 | oninput="this.setCustomValidity('')" |
| 10 | placeholder="12345" |
| 11 | /> |
| 12 | |
| 13 | <!-- Approach 2: JavaScript with setCustomValidity --> |
| 14 | <form id="contactForm" novalidate> |
| 15 | <label for="phone">Phone Number</label> |
| 16 | <input |
| 17 | type="tel" |
| 18 | id="phone" |
| 19 | name="phone" |
| 20 | pattern="[+][0-9]{1,3}[0-9]{3,14}" |
| 21 | placeholder="+1-555-0123" |
| 22 | required |
| 23 | /> |
| 24 | <span id="phone-error" class="error-message"></span> |
| 25 | |
| 26 | <button type="submit">Submit</button> |
| 27 | </form> |
| 28 | |
| 29 | <script> |
| 30 | const contactForm = document.getElementById('contactForm'); |
| 31 | const phoneInput = document.getElementById('phone'); |
| 32 | const phoneError = document.getElementById('phone-error'); |
| 33 | |
| 34 | contactForm.addEventListener('submit', (e) => { |
| 35 | e.preventDefault(); |
| 36 | |
| 37 | // Clear previous custom validity |
| 38 | phoneInput.setCustomValidity(''); |
| 39 | |
| 40 | if (phoneInput.validity.valueMissing) { |
| 41 | phoneInput.setCustomValidity('Phone number is required'); |
| 42 | } else if (phoneInput.validity.patternMismatch) { |
| 43 | phoneInput.setCustomValidity( |
| 44 | 'Please enter a valid number with country code, e.g. +1-555-0123' |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | if (!phoneInput.checkValidity()) { |
| 49 | phoneError.textContent = phoneInput.validationMessage; |
| 50 | phoneInput.focus(); |
| 51 | } else { |
| 52 | phoneError.textContent = ''; |
| 53 | alert('Phone number is valid!'); |
| 54 | } |
| 55 | }); |
| 56 | |
| 57 | // Clear error on input |
| 58 | phoneInput.addEventListener('input', () => { |
| 59 | phoneInput.setCustomValidity(''); |
| 60 | phoneError.textContent = ''; |
| 61 | }); |
| 62 | </script> |
| 63 | |
| 64 | <!-- Approach 3: Custom validation on blur --> |
| 65 | <input |
| 66 | type="password" |
| 67 | id="password" |
| 68 | name="password" |
| 69 | required |
| 70 | minlength="8" |
| 71 | /> |
| 72 | |
| 73 | <script> |
| 74 | const passwordInput = document.getElementById('password'); |
| 75 | |
| 76 | passwordInput.addEventListener('blur', () => { |
| 77 | const value = passwordInput.value; |
| 78 | |
| 79 | if (value.length === 0) { |
| 80 | passwordInput.setCustomValidity('Password is required'); |
| 81 | } else if (value.length < 8) { |
| 82 | passwordInput.setCustomValidity( |
| 83 | 'Password must be at least 8 characters' |
| 84 | ); |
| 85 | } else if (!/[A-Z]/.test(value)) { |
| 86 | passwordInput.setCustomValidity( |
| 87 | 'Password must contain at least one uppercase letter' |
| 88 | ); |
| 89 | } else if (!/[0-9]/.test(value)) { |
| 90 | passwordInput.setCustomValidity( |
| 91 | 'Password must contain at least one number' |
| 92 | ); |
| 93 | } else { |
| 94 | passwordInput.setCustomValidity(''); |
| 95 | } |
| 96 | |
| 97 | passwordInput.reportValidity(); |
| 98 | }); |
| 99 | </script> |
info
CSS pseudo-classes let you style form controls based on their validation state without JavaScript. The browser applies these classes automatically as validation state changes — on keystroke, on blur, and on submit. The newer :user-valid and :user-invalid pseudo-classes only apply after the user has interacted with the field, preventing premature error indicators.
| Pseudo-class | Applied When | Use Case |
|---|---|---|
| :valid | Field passes all constraints | Show green border or check icon |
| :invalid | Field fails any constraint | Show red border on submit (applies on page load for required empty fields) |
| :user-valid | User has interacted + valid | Show success styling after user types |
| :user-invalid | User has interacted + invalid | Show error styling only after user types |
| :required | Field has required attribute | Style required field indicators |
| :optional | Field lacks required attribute | Subtle styling for optional fields |
| :in-range | Value within min/max bounds | Range slider inside valid range |
| :out-of-range | Value outside min/max bounds | Range slider outside valid range |
| :focus-within | Element or descendant has focus | Highlight entire field group on focus |
| :placeholder-shown | Placeholder is currently visible | Hide validation styles until user types |
| 1 | /* Base input styles */ |
| 2 | input { |
| 3 | border: 1px solid #d1d5db; |
| 4 | padding: 10px 12px; |
| 5 | border-radius: 6px; |
| 6 | outline: none; |
| 7 | transition: border-color 0.2s, box-shadow 0.2s; |
| 8 | width: 100%; |
| 9 | font-size: 14px; |
| 10 | } |
| 11 | |
| 12 | /* Valid state */ |
| 13 | input:valid { |
| 14 | border-color: #22c55e; |
| 15 | } |
| 16 | |
| 17 | /* Invalid state (applies immediately on page load for required) */ |
| 18 | input:invalid { |
| 19 | border-color: #d1d5db; /* Neutral until user interaction */ |
| 20 | } |
| 21 | |
| 22 | /* User-interacted invalid — show error */ |
| 23 | input:user-invalid { |
| 24 | border-color: #ef4444; |
| 25 | box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); |
| 26 | } |
| 27 | |
| 28 | /* User-interacted valid — show success */ |
| 29 | input:user-valid { |
| 30 | border-color: #22c55e; |
| 31 | box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); |
| 32 | } |
| 33 | |
| 34 | /* Focus state overrides */ |
| 35 | input:focus { |
| 36 | border-color: #3b82f6; |
| 37 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); |
| 38 | } |
| 39 | |
| 40 | input:focus:user-invalid { |
| 41 | border-color: #ef4444; |
| 42 | box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); |
| 43 | } |
| 44 | |
| 45 | input:focus:user-valid { |
| 46 | border-color: #22c55e; |
| 47 | box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2); |
| 48 | } |
| 49 | |
| 50 | /* Disabled */ |
| 51 | input:disabled { |
| 52 | opacity: 0.5; |
| 53 | cursor: not-allowed; |
| 54 | background: #f9fafb; |
| 55 | } |
| 56 | |
| 57 | /* Required field label indicator */ |
| 58 | label:has(+ input:required)::after, |
| 59 | label:has(+ input:required)::after { |
| 60 | content: " *"; |
| 61 | color: #ef4444; |
| 62 | } |
| 63 | |
| 64 | /* Range slider states */ |
| 65 | input[type="range"]:in-range { |
| 66 | accent-color: #22c55e; |
| 67 | } |
| 68 | |
| 69 | input[type="range"]:out-of-range { |
| 70 | accent-color: #ef4444; |
| 71 | } |
| 72 | |
| 73 | /* Error message styling */ |
| 74 | .error-message { |
| 75 | color: #ef4444; |
| 76 | font-size: 12px; |
| 77 | margin-top: 4px; |
| 78 | display: none; |
| 79 | } |
| 80 | |
| 81 | input:user-invalid ~ .error-message { |
| 82 | display: block; |
| 83 | } |
| 84 | |
| 85 | /* Success message */ |
| 86 | .success-message { |
| 87 | color: #22c55e; |
| 88 | font-size: 12px; |
| 89 | margin-top: 4px; |
| 90 | display: none; |
| 91 | } |
| 92 | |
| 93 | input:user-valid ~ .success-message { |
| 94 | display: block; |
| 95 | } |
best practice
Validation can happen at different times: on keystroke (real-time), on blur (when the field loses focus), on form submit, or a combination. Each approach has different UX implications. Real-time validation catches errors early but can be annoying if messages appear before the user finishes typing. Submit validation is less intrusive but delays feedback.
| Strategy | Trigger | Pros | Cons |
|---|---|---|---|
| Submit validation | Form submission | Simple, no premature errors | User fills entire form before seeing any error |
| Blur validation | Field loses focus | Validates after user finishes each field | May not see error if moving quickly |
| Real-time validation | On each keystroke | Instant feedback | Can be distracting; premature errors while typing |
| Hybrid | Blur + submit | Best UX — validate on blur, re-validate on submit | Slightly more code |
| Debounced real-time | Keystroke with delay | Real-time feel without constant checking | Implementation complexity |
| 1 | <form id="hybridForm" novalidate> |
| 2 | <div class="field"> |
| 3 | <label for="h-name">Full Name</label> |
| 4 | <input |
| 5 | type="text" |
| 6 | id="h-name" |
| 7 | name="name" |
| 8 | required |
| 9 | minlength="2" |
| 10 | pattern="[A-Za-z\s]+" |
| 11 | /> |
| 12 | <span class="error-msg" id="h-name-error"></span> |
| 13 | </div> |
| 14 | |
| 15 | <div class="field"> |
| 16 | <label for="h-email">Email</label> |
| 17 | <input |
| 18 | type="email" |
| 19 | id="h-email" |
| 20 | name="email" |
| 21 | required |
| 22 | /> |
| 23 | <span class="error-msg" id="h-email-error"></span> |
| 24 | </div> |
| 25 | |
| 26 | <button type="submit">Submit</button> |
| 27 | </form> |
| 28 | |
| 29 | <script> |
| 30 | const hybridForm = document.getElementById('hybridForm'); |
| 31 | const fields = [ |
| 32 | { input: document.getElementById('h-name'), error: document.getElementById('h-name-error') }, |
| 33 | { input: document.getElementById('h-email'), error: document.getElementById('h-email-error') }, |
| 34 | ]; |
| 35 | |
| 36 | // Real-time validation: show/hide error messages on input |
| 37 | // but only after the field has been blurred at least once |
| 38 | fields.forEach(({ input, error }) => { |
| 39 | let hasBeenBlurred = false; |
| 40 | |
| 41 | input.addEventListener('blur', () => { |
| 42 | hasBeenBlurred = true; |
| 43 | validateField(input, error); |
| 44 | }); |
| 45 | |
| 46 | input.addEventListener('input', () => { |
| 47 | if (hasBeenBlurred) { |
| 48 | validateField(input, error); |
| 49 | } |
| 50 | }); |
| 51 | }); |
| 52 | |
| 53 | function validateField(input, errorEl) { |
| 54 | input.setCustomValidity(''); |
| 55 | |
| 56 | // Check validity and set custom messages |
| 57 | if (input.validity.valueMissing) { |
| 58 | input.setCustomValidity('This field is required'); |
| 59 | } else if (input.validity.typeMismatch) { |
| 60 | input.setCustomValidity('Please enter a valid value'); |
| 61 | } else if (input.validity.patternMismatch) { |
| 62 | input.setCustomValidity('Format is incorrect'); |
| 63 | } else if (input.validity.tooShort) { |
| 64 | input.setCustomValidity('Value is too short'); |
| 65 | } |
| 66 | |
| 67 | if (!input.checkValidity()) { |
| 68 | errorEl.textContent = input.validationMessage; |
| 69 | input.setAttribute('aria-invalid', 'true'); |
| 70 | } else { |
| 71 | errorEl.textContent = ''; |
| 72 | input.removeAttribute('aria-invalid'); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // Submit validation: check all fields again |
| 77 | hybridForm.addEventListener('submit', (e) => { |
| 78 | e.preventDefault(); |
| 79 | let hasError = false; |
| 80 | |
| 81 | fields.forEach(({ input, error }) => { |
| 82 | validateField(input, error); |
| 83 | if (!input.checkValidity()) { |
| 84 | hasError = true; |
| 85 | } |
| 86 | }); |
| 87 | |
| 88 | if (!hasError) { |
| 89 | alert('Form submitted successfully!'); |
| 90 | } |
| 91 | }); |
| 92 | </script> |
pro tip
Live preview of an interactive form with hybrid validation — errors appear on blur and are cleared on valid input:
Accessible form validation ensures screen reader users and keyboard-only users receive clear, timely feedback about errors. The key techniques are: linking error messages to inputs with aria-describedby, marking invalid fields with aria-invalid, and using role="alert" on error containers for immediate announcement.
| Technique | Implementation | Impact |
|---|---|---|
| aria-invalid | aria-invalid="true" | Screen reader announces field as invalid |
| aria-describedby | aria-describedby="error-id" | Links input to its error message |
| role="alert" | role="alert" on error div | Immediately announces dynamic error content |
| aria-live | aria-live="polite" or "assertive" | Region that announces content changes |
| aria-errormessage | aria-errormessage="error-id" | Explicitly identifies the element containing the error |
| Focus management | input.focus() on error | Moves focus to first invalid field |
| Summary | aria-live region with error count | Provides overview of all errors |
| 1 | <!-- Accessible validation form --> |
| 2 | <form id="accessibleForm" novalidate> |
| 3 | <div class="form-group"> |
| 4 | <label for="fullname">Full Name</label> |
| 5 | <input |
| 6 | type="text" |
| 7 | id="fullname" |
| 8 | name="fullname" |
| 9 | required |
| 10 | minlength="2" |
| 11 | aria-required="true" |
| 12 | aria-describedby="fullname-error" |
| 13 | /> |
| 14 | <span |
| 15 | id="fullname-error" |
| 16 | class="error-message" |
| 17 | role="alert" |
| 18 | aria-live="assertive" |
| 19 | ></span> |
| 20 | </div> |
| 21 | |
| 22 | <div class="form-group"> |
| 23 | <label for="email">Email Address</label> |
| 24 | <input |
| 25 | type="email" |
| 26 | id="email" |
| 27 | name="email" |
| 28 | required |
| 29 | aria-required="true" |
| 30 | aria-describedby="email-error" |
| 31 | /> |
| 32 | <span |
| 33 | id="email-error" |
| 34 | class="error-message" |
| 35 | role="alert" |
| 36 | aria-live="polite" |
| 37 | ></span> |
| 38 | </div> |
| 39 | |
| 40 | <button type="submit">Submit</button> |
| 41 | </form> |
| 42 | |
| 43 | <!-- Error summary region --> |
| 44 | <div |
| 45 | id="error-summary" |
| 46 | class="error-summary" |
| 47 | role="alert" |
| 48 | aria-live="polite" |
| 49 | tabindex="-1" |
| 50 | hidden |
| 51 | > |
| 52 | <h3>Please fix the following errors:</h3> |
| 53 | <ul id="error-list"></ul> |
| 54 | </div> |
| 55 | |
| 56 | <script> |
| 57 | const aForm = document.getElementById('accessibleForm'); |
| 58 | const errorSummary = document.getElementById('error-summary'); |
| 59 | const errorList = document.getElementById('error-list'); |
| 60 | |
| 61 | aForm.addEventListener('submit', (e) => { |
| 62 | e.preventDefault(); |
| 63 | const errors = []; |
| 64 | |
| 65 | const fields = [ |
| 66 | { input: document.getElementById('fullname'), name: 'Full Name' }, |
| 67 | { input: document.getElementById('email'), name: 'Email' }, |
| 68 | ]; |
| 69 | |
| 70 | fields.forEach(({ input, name }) => { |
| 71 | const errorEl = document.getElementById(input.id + '-error'); |
| 72 | input.setCustomValidity(''); |
| 73 | |
| 74 | if (input.validity.valueMissing) { |
| 75 | const msg = name + ' is required'; |
| 76 | input.setCustomValidity(msg); |
| 77 | errors.push(msg); |
| 78 | } else if (input.validity.typeMismatch) { |
| 79 | const msg = 'Please enter a valid ' + name.toLowerCase(); |
| 80 | input.setCustomValidity(msg); |
| 81 | errors.push(msg); |
| 82 | } else if (input.validity.tooShort) { |
| 83 | const msg = name + ' is too short'; |
| 84 | input.setCustomValidity(msg); |
| 85 | errors.push(msg); |
| 86 | } |
| 87 | |
| 88 | if (!input.checkValidity()) { |
| 89 | errorEl.textContent = input.validationMessage; |
| 90 | input.setAttribute('aria-invalid', 'true'); |
| 91 | } else { |
| 92 | errorEl.textContent = ''; |
| 93 | input.removeAttribute('aria-invalid'); |
| 94 | } |
| 95 | }); |
| 96 | |
| 97 | // Update error summary |
| 98 | if (errors.length > 0) { |
| 99 | errorList.innerHTML = errors |
| 100 | .map((e) => '<li>' + e + '</li>') |
| 101 | .join(''); |
| 102 | errorSummary.hidden = false; |
| 103 | errorSummary.focus(); |
| 104 | } else { |
| 105 | errorSummary.hidden = true; |
| 106 | alert('Form submitted successfully!'); |
| 107 | } |
| 108 | }); |
| 109 | |
| 110 | // Clear individual errors on input |
| 111 | aForm.querySelectorAll('input').forEach((input) => { |
| 112 | input.addEventListener('input', () => { |
| 113 | input.setCustomValidity(''); |
| 114 | const errorEl = document.getElementById(input.id + '-error'); |
| 115 | errorEl.textContent = ''; |
| 116 | input.removeAttribute('aria-invalid'); |
| 117 | }); |
| 118 | }); |
| 119 | </script> |
best practice
Validation Design Principles
Common Pitfalls
| Pitfall | Why It Hurts | Solution |
|---|---|---|
| Client-only validation | Easily bypassed — no real security | Always re-validate on the server |
| Unclear error messages | Users don't know how to fix the error | Be specific about what's required |
| Real-time validation on empty fields | Red borders before user types | Use :user-invalid or validate on blur |
| No focus management | Screen reader users don't know about errors | Focus first invalid field or summary |
| Missing aria attributes | Errors invisible to screen readers | Use aria-invalid and aria-describedby |
| Password requirements hidden | Users guess invalid passwords | Show requirements before/during typing |
warning
Live preview of a complete validation demo with all best practices applied — accessible, user-friendly, with proper styling: