HTML5 form validation is built into the browser and works without any JavaScript. But combining native validation with JavaScript gives you full control over messages, async validation, and accessible error states. Firefox has robust support for the Constraint Validation API.
Native HTML Validation
HTML attributes handle common validation rules:
<form id="signup">
<!-- Required field -->
<input type="text" name="name" required>
<!-- Email format -->
<input type="email" name="email" required>
<!-- Length constraints -->
<input type="password" name="password"
minlength="8" maxlength="128" required>
<!-- Number range -->
<input type="number" name="age" min="13" max="120">
<!-- Pattern matching (regex) -->
<input type="text" name="username"
pattern="[a-zA-Z0-9_]{3,20}"
title="Letters, numbers, and underscores, 3-20 characters"
required>
<!-- URL format -->
<input type="url" name="website" placeholder="https://...">
<!-- Date range -->
<input type="date" name="birthday"
max="2010-01-01" min="1900-01-01">
<button type="submit">Create account</button>
</form>
The browser validates automatically on submit and shows native error messages. In Firefox, these appear as styled tooltips below the field.
The Constraint Validation API
JavaScript access to validation state:
const input = document.querySelector('input[name="email"]');
// Validate a specific element
input.validity.valid // true if passes all constraints
input.validity.valueMissing // true if required and empty
input.validity.typeMismatch // true if type=email but not an email
input.validity.tooShort // true if shorter than minlength
input.validity.tooLong // true if longer than maxlength
input.validity.rangeUnderflow // true if less than min
input.validity.rangeOverflow // true if greater than max
input.validity.patternMismatch // true if doesn't match pattern
input.validity.customError // true if setCustomValidity called
// Validation message
input.validationMessage // "Please fill out this field." etc.
// Check if a form is valid
const form = document.getElementById('signup');
form.checkValidity() // returns boolean
form.reportValidity() // returns boolean AND shows native UI
Custom Validation Messages
Override the browser's default messages:
const passwordInput = document.querySelector('[name="password"]');
passwordInput.addEventListener('input', () => {
const value = passwordInput.value;
if (value.length < 8) {
passwordInput.setCustomValidity('Password must be at least 8 characters.');
} else if (!/[A-Z]/.test(value)) {
passwordInput.setCustomValidity('Password must contain at least one uppercase letter.');
} else if (!/[0-9]/.test(value)) {
passwordInput.setCustomValidity('Password must contain at least one number.');
} else {
passwordInput.setCustomValidity(''); // Clear custom error (valid)
}
});
Important: setCustomValidity('') clears the custom error. If you don't clear it, the field stays invalid even when the user fixes it.
Preventing Default Submission and Showing Custom UI
Don't use the browser's native error UI — build your own for better UX:
const form = document.getElementById('signup');
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Reset previous errors
document.querySelectorAll('.error-message').forEach(el => el.remove());
document.querySelectorAll('.field-error').forEach(el => el.classList.remove('field-error'));
// Check validity
if (!form.checkValidity()) {
// Show errors for each invalid field
for (const field of form.elements) {
if (!field.validity.valid) {
showError(field, field.validationMessage);
}
}
return;
}
// Async validation (server-side check)
const emailField = form.querySelector('[name="email"]');
const taken = await checkEmailTaken(emailField.value);
if (taken) {
showError(emailField, 'This email is already registered.');
return;
}
// Submit
submitForm(new FormData(form));
});
function showError(field, message) {
field.classList.add('field-error');
const errorEl = document.createElement('p');
errorEl.className = 'error-message';
errorEl.textContent = message;
errorEl.setAttribute('role', 'alert'); // Announces to screen readers
field.insertAdjacentElement('afterend', errorEl);
}
.field-error {
border-color: #EF4444;
outline-color: #EF4444;
}
.error-message {
color: #DC2626;
font-size: 0.875rem;
margin-top: 4px;
}
Real-Time Validation
Validate as the user types, but only after first interaction:
const emailField = document.querySelector('[name="email"]');
let hasInteracted = false;
emailField.addEventListener('blur', () => {
hasInteracted = true;
validateField(emailField);
});
emailField.addEventListener('input', () => {
if (hasInteracted) validateField(emailField);
});
function validateField(field) {
// Remove existing error
const existingError = field.parentNode.querySelector('.error-message');
existingError?.remove();
field.classList.remove('field-error');
if (!field.validity.valid) {
showError(field, field.validationMessage);
}
}
Accessible Validation
Link error messages to their fields with aria-describedby:
<div class="field-group">
<label for="email">Email</label>
<input type="email" id="email" name="email"
required aria-describedby="email-error">
<p id="email-error" class="error-message" role="alert" hidden>
Please enter a valid email address.
</p>
</div>
function showError(field, message) {
const errorId = `${field.id}-error`;
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement('p');
errorEl.id = errorId;
errorEl.className = 'error-message';
errorEl.setAttribute('role', 'alert');
field.setAttribute('aria-describedby', errorId);
field.insertAdjacentElement('afterend', errorEl);
}
errorEl.textContent = message;
errorEl.hidden = false;
field.setAttribute('aria-invalid', 'true');
}
function clearError(field) {
const errorId = `${field.id}-error`;
const errorEl = document.getElementById(errorId);
if (errorEl) errorEl.hidden = true;
field.removeAttribute('aria-invalid');
}
novalidate + Custom Validation
Use novalidate on the form to disable browser's native validation UI while still using the Constraint Validation API for checkValidity():
<form id="signup" novalidate>
<!-- novalidate prevents native error tooltips -->
<!-- But checkValidity() still works -->
</form>
This gives you full control over the error UI while keeping native constraint checking.
Firefox DevTools for Form Debugging
- In the Inspector, look for
:valid/:invalidpseudo-class states in element states - The Console shows
form.checkValidity()results - Firefox respects
patternattribute in HTML5 forms and the DevTools Network panel shows the actual form data submitted
CSS targeting validation states:
input:valid { border-color: #10B981; }
input:invalid:not(:placeholder-shown) { border-color: #EF4444; }
/* :placeholder-shown trick: don't show invalid state on empty required fields */
/* before the user has interacted */
Top comments (0)