On this page
Browser validation is great at catching problems but not always great at explaining them. "Please match the requested format" doesn't tell anyone what the format is. And the little bubble disappears after a few seconds and looks different in every browser.
There are two ways to show better messages. Start with the simple one; move to the second when you need full control over design.
Approach 1: Change the browser's message with setCustomValidity
Keep the native bubble, but replace its text:
<label for="username">Username</label>
<input id="username" name="username" required pattern="[a-z0-9_]{3,20}">
const username = document.getElementById('username');
username.addEventListener('invalid', function () {
if (username.validity.valueMissing) {
username.setCustomValidity('Please choose a username.');
} else if (username.validity.patternMismatch) {
username.setCustomValidity('Use 3–20 lowercase letters, numbers or underscores.');
}
});
// Clear the custom message as soon as the user edits, so the field can become valid again.
username.addEventListener('input', function () {
username.setCustomValidity('');
});
The input listener is essential. Once setCustomValidity is given a non-empty string, the field stays invalid until you clear it — forgetting this is the classic "my form will never submit" bug.
Simplest of all: for pattern, a title attribute is included in the browser's message:
<input name="ref" pattern="[A-Z]{2}[0-9]{4}" title="Two capital letters then four digits, e.g. AB1234">
Approach 2: Fully custom inline error messages
For complete control, turn off the bubbles with novalidate and render your own messages under each field — while still using the browser's validation logic to decide what's wrong.
The HTML
<form id="signup" action="https://api.formclick.io/submit" method="POST" novalidate>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required aria-describedby="email-error">
<p class="error" id="email-error" hidden></p>
</div>
<div class="field">
<label for="age">Age</label>
<input id="age" name="age" type="number" min="18" max="120" required aria-describedby="age-error">
<p class="error" id="age-error" hidden></p>
</div>
<button type="submit">Sign up</button>
</form>
The messages
Write one message per kind of problem, in plain language:
const MESSAGES = {
email: {
valueMissing: 'Enter your email address.',
typeMismatch: 'Enter an email address like name@example.com.',
},
age: {
valueMissing: 'Enter your age.',
rangeUnderflow: 'You must be 18 or over to sign up.',
rangeOverflow: 'Please check your age.',
badInput: 'Enter your age as a number.',
},
};
function messageFor(field) {
const custom = MESSAGES[field.name] || {};
for (const key in field.validity) {
if (key !== 'valid' && field.validity[key]) {
return custom[key] || field.validationMessage; // fall back to the browser's text
}
}
return '';
}
Showing and clearing errors
const form = document.getElementById('signup');
function showError(field) {
const error = document.getElementById(field.id + '-error');
const message = messageFor(field);
error.textContent = message;
error.hidden = !message;
field.setAttribute('aria-invalid', message ? 'true' : 'false');
return !message;
}
form.querySelectorAll('input').forEach(function (field) {
// Validate when the user leaves the field…
field.addEventListener('blur', function () { if (field.value) showError(field); });
// …and re-check while they fix an existing error.
field.addEventListener('input', function () {
if (field.getAttribute('aria-invalid') === 'true') showError(field);
});
});
form.addEventListener('submit', function (event) {
const fields = Array.from(form.querySelectorAll('input:not([type="hidden"])'));
const invalid = fields.filter(function (f) { return !showError(f); });
if (invalid.length) {
event.preventDefault();
invalid[0].focus();
}
});
The CSS
.error {
margin: 6px 0 0;
font-size: 14px;
color: #b91c1c;
}
.error::before { content: "⚠ "; }
input[aria-invalid="true"] {
border-color: #dc2626;
background: #fef2f2;
}
Why this pattern is accessible
aria-describedbylinks each field to its error, so screen readers read the message when the field is focused.aria-invalid="true"tells assistive technology the field has a problem.- Focus moves to the first invalid field on submit.
- The message uses text and an icon, not just a red border.
Writing good error messages
| Instead of | Write |
|---|---|
| Invalid input | Enter an email address like name@example.com |
| Field required | Enter your phone number so we can confirm the booking |
| Error: pattern mismatch | Use 8 digits, without spaces |
| Value out of range | You must be 18 or over to sign up |
Rules of thumb:
- Say what to do, not just what went wrong.
- Be specific about formats and limits.
- Don't blame — "Enter a date" beats "You entered an invalid date".
- Keep it short — one sentence.
Server errors count too
Even with perfect client-side messages, a submission can fail on the server — a network error, a spam check, a rate limit. When submitting with JavaScript, show the server's message in the same style:
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
const result = await response.json();
if (!result.success) {
formError.textContent = result.message || 'Something went wrong. Please try again.';
}
Formclick returns JSON with a success flag and a message, so you can show it directly. See How to Submit HTML Forms Using the Fetch API for the full pattern, and HTML Form Validation Explained for the fundamentals.



