Outside a framework, checking that a "confirm password" field matches the original password doesn't need a hand-rolled error-message div and manual show/hide logic — the browser's built-in Constraint Validation API can display its own native validation UI for a custom check, the same way it does for required or type="email".
1. The basic HTML
<form id="signup-form">
<input type="password" id="password" required>
<input type="password" id="confirm-password" required>
<button type="submit">Sign up</button>
</form>
2. Using setCustomValidity() for the native validation message
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirm-password');
function checkPasswordsMatch() {
if (confirmPassword.value !== password.value) {
confirmPassword.setCustomValidity('Passwords do not match');
} else {
confirmPassword.setCustomValidity(''); // clears the custom error — required for the form to submit
}
}
password.addEventListener('input', checkPasswordsMatch);
confirmPassword.addEventListener('input', checkPasswordsMatch);
setCustomValidity() plugs a custom message into the browser's own native validation system — once set to a non-empty string, the field is considered invalid, and the browser shows its normal built-in validation bubble with that message when the form is submitted, exactly as it would for a native required failure. Setting it back to an empty string is what marks the field valid again; forgetting that step leaves the field permanently blocked from submitting even after the passwords actually match.
3. Why this is often better than a hand-rolled error message
The Constraint Validation API's built-in message bubble is automatically positioned near the field, respects the user's browser and OS accessibility settings, and is announced correctly by screen readers without any additional ARIA work — a custom-built error <div> toggled with JavaScript has to replicate all of that manually to reach the same level of accessibility, and often doesn't.
4. Preventing the invalid state from blocking submission unexpectedly
document.getElementById('signup-form').addEventListener('submit', (e) => {
checkPasswordsMatch(); // ensure the check has run at least once before submit
if (!confirmPassword.checkValidity()) {
e.preventDefault();
}
});
checkValidity() triggers the browser's native validation check (including any custom validity message set) and returns whether the field currently passes — checking it explicitly on submit, in addition to the live input listeners, guards against a submission attempt before the user has typed into the confirm field at all.
5. Styling the invalid state
input:invalid {
border-color: #d33;
}
input:valid {
border-color: #2a2;
}
The native :invalid and :valid CSS pseudo-classes automatically reflect a field's current constraint-validation state, including any custom validity set via setCustomValidity() — this means visual feedback (a red border, for instance) can be added with plain CSS, no JavaScript class-toggling required, since the browser already tracks and exposes that state itself.
6. A note on where the real security check belongs
This client-side check exists purely for immediate user feedback — the actual password confirmation logic still needs to be enforced server-side (matching any backend validation the framework provides), since client-side JavaScript validation can always be bypassed by disabling it, submitting the form directly, or calling the API without a browser at all.