Contact form validation
The browser already validates required fields, email addresses and minimum lengths. This adds messages in your own words, in your own markup, without giving up any of the accessibility that comes free with the native constraints.
Replace YOUR_FORM_ID with a form of your own and this sends to your inbox.
<form action="https://submit.formpost.ai/YOUR_FORM_ID" method="POST" novalidate>
<h2>Contact us</h2>
<p class="lede">Everything marked with * is required.</p>
<label for="name">Name *</label>
<input id="name" type="text" name="name" required minlength="2"
autocomplete="name">
<p class="error" id="name-error"></p>
<label for="email">Email *</label>
<input id="email" type="email" name="email" required
autocomplete="email">
<p class="error" id="email-error"></p>
<label for="message">Message *</label>
<textarea id="message" name="message" rows="3" required minlength="10"></textarea>
<p class="hint">At least ten characters, so we know what you need.</p>
<p class="error" id="message-error"></p>
<button type="submit">Send message</button>
</form>const form = document.querySelector("form");
// novalidate on the form turns off the browser's own bubbles but keeps the
// constraints themselves — checkValidity() and validity still work.
form.addEventListener("submit", (event) => {
let firstBad = null;
for (const field of form.elements) {
if (!field.name) continue;
const error = document.querySelector("#" + field.id + "-error");
if (!error) continue;
if (field.checkValidity()) {
error.textContent = "";
field.removeAttribute("aria-invalid");
} else {
error.textContent = messageFor(field);
field.setAttribute("aria-invalid", "true");
field.setAttribute("aria-describedby", error.id);
firstBad = firstBad || field;
}
}
if (firstBad) {
event.preventDefault();
// Move focus, do not just scroll. A screen reader user gets nothing
// from a page that quietly jumped somewhere.
firstBad.focus();
}
});
// Say what is wrong and what to do, not "invalid input".
function messageFor(field) {
const v = field.validity;
if (v.valueMissing) return "This one is needed.";
if (v.typeMismatch) return "That does not look like an email address.";
if (v.tooShort) return "A little more detail, please.";
return field.validationMessage;
}body {
margin: 0;
padding: 32px 16px;
background: #fafafa;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #171717;
}
form {
display: flex;
flex-direction: column;
max-width: 430px;
margin: 0 auto;
padding: 28px;
background: #fff;
border: 1px solid #ededed;
border-radius: 14px;
}
h2 {
margin: 0 0 4px;
font-size: 20px;
}
.lede {
margin: 0 0 20px;
font-size: 14px;
color: #737373;
}
label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: #404040;
}
input,
textarea {
box-sizing: border-box;
font: inherit;
margin-bottom: 16px;
padding: 10px 12px;
color: #171717;
background: #fff;
border: 1px solid #d4d4d4;
border-radius: 8px;
}
input:focus,
textarea:focus {
border-color: #171717;
outline: 2px solid rgba(23, 23, 23, 0.12);
}
textarea {
resize: vertical;
}
button {
padding: 11px 16px;
font: inherit;
font-weight: 600;
color: #fff;
background: #171717;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.hint {
margin: -8px 0 16px;
font-size: 12px;
color: #a3a3a3;
}
.error {
margin: 0 0 16px;
font-size: 13px;
color: #dc2626;
}
/* Empty until the handler writes into it, and it must take no space until then */
.error:empty {
display: none;
}
/* The field directly above a filled-in error tightens up, so the message reads
as belonging to it. :has() means no wrapper element and no extra class. */
input:has(+ .error:not(:empty)),
textarea:has(+ .error:not(:empty)) {
margin-bottom: 6px;
}
[aria-invalid="true"] {
border-color: #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12);
}Constraints first, JavaScript second
required, type="email" and minlength do most of the work and cost nothing. They also work before your script loads and are understood by assistive technology without any help. Write those first, then take over the presentation of the errors — never replace the constraints themselves with JavaScript checks.
novalidate keeps the rules, drops the bubbles
Adding novalidate to the form stops the browser showing its own error popups, which are unstyleable and appear one at a time. The constraints stay live: checkValidity() and the validity object still work exactly as before, so you get to render the errors yourself while the browser keeps deciding what is valid.
if (field.checkValidity()) {
// clear
} else {
error.textContent = messageFor(field);
field.setAttribute("aria-invalid", "true");
}Write messages that say what to do
The validity object tells you why a field failed — valueMissing, typeMismatch, tooShort, patternMismatch — so you can say something specific. That does not look like an email address is useful. Invalid input is not. The default validationMessage is a reasonable fallback for cases you have not written a message for.
Make the errors reachable
- aria-invalid="true" on the field tells assistive technology it was rejected.
- aria-describedby pointing at the error paragraph is what makes the message read out with the field.
- Move focus to the first invalid field. Scrolling to it is not enough — a screen reader user has no idea the page jumped.
- Put the error text next to the field, not in a summary at the top. On a long form a summary helps as well, but never instead.
- Never rely on colour alone. The red border here is accompanied by text.
Questions about this contact form
- How do I show custom validation messages on a contact form?
- Add novalidate to the form to suppress the browser's bubbles, then read field.validity in a submit handler and write your own text into an element next to each field. The constraints keep working.
- Should I use :invalid or :user-invalid in CSS?
- :user-invalid. A required empty field matches :invalid the moment the page loads, so styling that turns every field red before anyone has typed. :user-invalid waits until the visitor has interacted with it.
- Is client-side validation enough?
- For the visitor's experience, yes. For correctness, no — anything can post directly to an endpoint and never see your page. The server validates again, which is why an empty submission is rejected even if your markup allowed it.
- How do I validate an email address properly?
- Use type="email" and stop. Hand-written email regexes reject valid addresses far more often than they catch invalid ones, and the only real proof an address works is sending something to it.
Related examples
CSS contact form
Every state styled: hover, focus-visible, invalid, disabled, placeholder.
JavaScript contact form
Vanilla fetch with a real status message and proper error handling.
Contact form with file upload
One enctype and a file input. Attachments ride along with the email.
Or go back to all 21 contact form examples.
Give this form an endpoint
Formpost takes the submission, emails it to you and keeps a searchable copy. Free for 250 messages a month, with unlimited forms and no card.