Client-side validation
Validate a contact form in the browser with built-in HTML constraints and custom messages before it is sent.
Browsers can validate a form on their own: mark a field required, give it a type or a pattern, and the browser refuses to submit until the value fits. This page builds on that built-in validation instead of replacing it, adds clearer messages with a few lines of JavaScript, and shows what SilentForms still checks on its side.
Built-in constraints#
These attributes need no JavaScript and work in every current browser:
| Attribute | What it checks |
|---|---|
required | The field is not empty. For a checkbox, that it is ticked. |
type="email" | The value looks like an email address. |
type="tel", type="url" | url checks the format; tel brings up the phone keypad but checks nothing on its own. |
minlength, maxlength | Length of the text in characters. |
pattern | The whole value matches a regular expression. Used on input, not textarea. |
When the visitor presses Send with an invalid field, the browser focuses it and shows a tooltip, and no request is made.
The form#
<form id="contact" action="https://silentforms.com/api/submit" method="POST" novalidate>
<input type="hidden" name="accessKey" value="YOUR_ACCESS_KEY_HERE">
<div class="field">
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="name" required minlength="2">
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
</div>
<div class="field">
<label for="phone">Phone (optional)</label>
<input id="phone" name="phone" type="tel" autocomplete="tel"
pattern="[0-9 +\(\)\-]{7,20}" aria-describedby="phone-hint">
<small id="phone-hint">Digits, spaces, +, ( ) and - only.</small>
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="20" maxlength="5000"></textarea>
</div>
<input type="checkbox" name="botcheck" style="display: none;" tabindex="-1" autocomplete="off">
<button type="submit">Send message</button>
<p id="contact-status" role="status" aria-live="polite"></p>
</form>Current browsers compile pattern in the stricter Unicode Sets (v) mode of regular expressions, so characters such as (, ) and - inside square brackets must be escaped with a backslash. An invalid pattern is silently ignored rather than reported.
The novalidate attribute turns off the browser's own pop-up so the script can show messages its own way. The constraints still apply: the script reads them through the Constraint Validation API. If you do not add the script, remove novalidate, otherwise nothing is checked at all.
Styling invalid fields#
Use :user-invalid rather than :invalid. :invalid matches an empty required field as soon as the page loads, so the form starts out covered in red. :user-invalid only matches once the visitor has interacted with the field or tried to submit. It has been supported in all major browsers since late 2023 (MDN).
.field { margin-bottom: 1rem; }
.field input:user-invalid,
.field textarea:user-invalid {
border-color: #b91c1c;
outline-color: #b91c1c;
}
.field .error {
display: block;
color: #b91c1c;
font-size: 0.875rem;
margin-top: 0.25rem;
}Colour should not be the only signal, which is why the script below also writes the message as text next to the field.
Custom messages and validating before fetch#
<script>
const form = document.getElementById('contact');
const status = document.getElementById('contact-status');
const messages = {
name: { valueMissing: 'Please tell us your name.', tooShort: 'Your name needs at least 2 characters.' },
email: { valueMissing: 'We need your email to reply.', typeMismatch: 'That does not look like an email address.' },
phone: { patternMismatch: 'Use digits, spaces, +, ( ) or - only.' },
message: { valueMissing: 'Please write a message.', tooShort: 'Please write at least 20 characters.' },
};
function check(field) {
field.setCustomValidity('');
const rules = messages[field.name] || {};
const failed = Object.keys(rules).find((key) => field.validity[key]);
if (failed) field.setCustomValidity(rules[failed]);
let error = field.parentElement.querySelector('.error');
if (!error) {
error = document.createElement('span');
error.className = 'error';
error.id = field.id + '-error';
field.parentElement.append(error);
}
error.textContent = field.validationMessage;
field.setAttribute('aria-invalid', field.validity.valid ? 'false' : 'true');
field.setAttribute('aria-errormessage', error.id);
return field.validity.valid;
}
const fields = form.querySelectorAll('.field input, .field textarea');
fields.forEach((field) => field.addEventListener('blur', () => check(field)));
fields.forEach((field) => field.addEventListener('input', () => {
if (field.getAttribute('aria-invalid') === 'true') check(field);
}));
form.addEventListener('submit', async (event) => {
event.preventDefault();
const results = [...fields].map(check);
const firstInvalid = [...fields][results.indexOf(false)];
if (firstInvalid) {
firstInvalid.focus();
return;
}
status.textContent = 'Sending…';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
const result = await response.json().catch(() => ({}));
status.textContent = response.ok
? 'Thanks! Your message has been sent.'
: result.message || 'Something went wrong. Please try again.';
if (response.ok) form.reset();
} catch {
status.textContent = 'Could not reach the server. Please try again.';
}
});
</script>What the script does:
setCustomValidityreplaces the browser's generic text with your own. It must be cleared with an empty string before each check, or the field stays invalid forever: a non-empty custom message marks the field as invalid by itself.field.validitytells you which constraint failed (valueMissing,typeMismatch,tooShort,patternMismatch), so each gets its own message.- Fields are checked on blur, then again on every keystroke once they have shown an error, so the message disappears as soon as the value is fixed rather than nagging while the visitor is still typing.
- On submit, every field is checked and focus moves to the first invalid one. Nothing is sent until all pass.
aria-invalidandaria-errormessagelet screen readers announce the problem with the field.
What SilentForms still checks#
Browser validation is a convenience. Anyone can bypass it, and the endpoint never trusts it. Whatever the browser does, SilentForms:
- rejects an
emailfield that is not a valid address with "Email address is invalid.", and the same forreplytowith "Reply-to address is invalid.", - rejects a submission with no visitor fields at all with "The form has no fields to submit.",
- caps each value at 10,000 characters and strips control characters,
- refuses requests over 1 MB with "Submission is too large."
These come back in the JSON message, so the script above shows them like any other error. It does not check lengths or patterns you set in HTML: those are yours to enforce if they matter.
Next steps#
- The fetch pattern in more depth: AJAX form with JavaScript.
- Stop bots, not just typos: Honeypot and Cloudflare Turnstile.
- All error messages: Errors.