The browser’s validation bubbles are not styleable and are the same for everyone. For full control, write your own, and keep it simple and accessible.
A complete example
<style>
label { display: block; margin-top: 8px; }
input { padding: 6px; }
input[aria-invalid="true"] { border: 2px solid #c0392b; }
.error { color: #c0392b; font-size: 14px; min-height: 18px; margin: 2px 0 0; }
</style>
<form id="signup" novalidate>
<label for="email">Email</label>
<input id="email" name="email" type="email" aria-describedby="email-error">
<p id="email-error" class="error"></p>
<label for="age">Age</label>
<input id="age" name="age" type="number" aria-describedby="age-error">
<p id="age-error" class="error"></p>
<button type="submit">Sign up</button>
<p id="result" role="status"></p>
</form>
<script>
const form = document.querySelector("#signup");
const rules = {
email(value) {
if (value === "") return "Enter your email address.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "That does not look like an email address.";
return "";
},
age(value) {
if (value === "") return "Enter your age.";
if (Number(value) < 18) return "You must be 18 or older.";
return "";
}
};
function validateField(input) {
const message = rules[input.name](input.value.trim());
document.querySelector("#" + input.id + "-error").textContent = message;
input.setAttribute("aria-invalid", message ? "true" : "false");
return message === "";
}
form.addEventListener("submit", event => {
event.preventDefault();
const fields = [...form.querySelectorAll("input")];
const results = fields.map(validateField); // validate every field, not just the first
if (results.every(Boolean)) {
document.querySelector("#result").textContent = "Account created!";
} else {
fields.find(f => f.getAttribute("aria-invalid") === "true").focus();
}
});
// re-check a field when the user fixes it
form.addEventListener("input", event => {
if (event.target.getAttribute("aria-invalid") === "true") validateField(event.target);
});
</script>
What this example does well
novalidateturns off the browser’s popup so the form shows its own messages.- One place for rules.
rulesmaps each field name to a function that returns an error message, or""when fine. - Errors in text next to the field, linked with
aria-describedby, andaria-invalidfor assistive technology. - Every field is validated on submit, so users see all problems at once.
- Focus goes to the first invalid field.
- Errors clear while typing, once the user starts fixing them.
When to validate
| Moment | Good for |
|---|---|
| On submit | always |
| On blur (leaving a field) | formats, such as emails |
| On input | only after an error was shown, or for live hints (password strength) |
Do not show “required” errors while a user is still typing their first character.
Messages that help
“Invalid input” helps nobody. Say what to do: “Use at least 8 characters”, “Enter a date like 15/03/2026”.
Combine approaches
required, type="email") and read input.validity and input.validationMessage instead of writing every rule yourself. Use your own functions only for rules the browser cannot express.