Contact form
A contact form with real labels, inline validation and error and success states.
<form class="form" id="contact" novalidate>
<h2>Send us a message</h2>
<p class="intro">We reply within two working days.</p>
<div class="field">
<label for="name">Your name</label>
<input type="text" id="name" name="name" autocomplete="name" required
aria-describedby="name-err">
<p class="err" id="name-err" hidden>Please tell us your name.</p>
</div>
<div class="field">
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email" required
aria-describedby="email-hint email-err">
<p class="hint" id="email-hint">We only use this to reply.</p>
<p class="err" id="email-err" hidden>That does not look like an email address.</p>
</div>
<div class="field">
<label for="topic">What is this about?</label>
<select id="topic" name="topic">
<option>A question about an order</option>
<option>Returns and exchanges</option>
<option>Wholesale</option>
<option>Something else</option>
</select>
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required
aria-describedby="message-err"></textarea>
<p class="err" id="message-err" hidden>Please write a message.</p>
</div>
<label class="check">
<input type="checkbox" name="consent" required aria-describedby="consent-err">
<span>I am happy for Acme to store this message in order to reply.</span>
</label>
<p class="err" id="consent-err" hidden>We need your agreement before we can store the message.</p>
<!-- honeypot: real people never fill this in -->
<div class="pot" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<button class="submit" type="submit">Send message</button>
<p class="status" id="status" role="status"></p>
</form>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; background: #f6f8fb; padding: 24px; }
.form {
max-width: 480px;
margin: 0 auto;
padding: 28px 28px 30px;
background: #fff;
border: 1px solid #e2e7ef;
border-radius: 14px;
}
.form h2 { font-size: 1.3rem; color: #16202e; }
.intro { color: #8593ab; font-size: .9rem; margin-top: 4px; margin-bottom: 22px; }
.field { margin-bottom: 18px; }
label {
display: block;
font-size: .88rem;
font-weight: 500;
color: #16202e;
margin-bottom: 6px;
}
input[type="text"], input[type="email"], select, textarea {
width: 100%;
font: inherit;
font-size: .95rem;
padding: 10px 12px;
border: 1px solid #d9e0ea;
border-radius: 8px;
background: #fff;
color: #16202e;
}
textarea { resize: vertical; min-height: 110px; }
input:focus, select:focus, textarea:focus {
outline: 2px solid #3b4fe4;
outline-offset: 1px;
border-color: #3b4fe4;
}
.hint { font-size: .8rem; color: #8593ab; margin-top: 5px; }
.err {
font-size: .82rem;
color: #c93b3b;
margin-top: 6px;
display: flex;
gap: 6px;
align-items: flex-start;
}
.err::before { content: "!"; font-weight: 700; }
[aria-invalid="true"] { border-color: #c93b3b; }
[aria-invalid="true"]:focus { outline-color: #c93b3b; }
.check {
display: flex;
gap: 10px;
align-items: flex-start;
font-weight: 400;
font-size: .88rem;
color: #5b6b83;
margin-bottom: 20px;
cursor: pointer;
}
.check input { width: 16px; height: 16px; margin-top: 3px; accent-color: #3b4fe4; flex-shrink: 0; }
.submit {
width: 100%;
font: inherit;
font-size: .95rem;
font-weight: 500;
padding: 12px;
border: 0;
border-radius: 9px;
background: #3b4fe4;
color: #fff;
cursor: pointer;
}
.submit:hover { background: #2a3abf; }
.submit:disabled { background: #c9d3e2; cursor: not-allowed; }
.status { margin-top: 14px; font-size: .9rem; text-align: center; }
.status.ok { color: #1f8a5b; }
.status.bad { color: #c93b3b; }
/* the honeypot must be hidden from people but present for bots */
.pot { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }const form = document.getElementById("contact");
const status = document.getElementById("status");
const rules = {
name: (v) => v.trim().length > 0,
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()),
message: (v) => v.trim().length > 0,
};
function setError(field, bad) {
const msg = document.getElementById(field.id + "-err") ||
document.getElementById(field.name + "-err");
field.setAttribute("aria-invalid", bad ? "true" : "false");
if (msg) msg.hidden = !bad;
return !bad;
}
// validate on blur, but only re-validate on input once a field has already failed
Object.keys(rules).forEach((id) => {
const el = document.getElementById(id);
el.addEventListener("blur", () => setError(el, !rules[id](el.value)));
el.addEventListener("input", () => {
if (el.getAttribute("aria-invalid") === "true") setError(el, !rules[id](el.value));
});
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
status.textContent = "";
status.className = "status";
// a filled honeypot means a bot; pretend it worked
if (form.website.value) { form.reset(); return; }
let firstBad = null;
Object.keys(rules).forEach((id) => {
const el = document.getElementById(id);
if (!setError(el, !rules[id](el.value)) && !firstBad) firstBad = el;
});
const consent = form.consent;
const consentBad = !consent.checked;
document.getElementById("consent-err").hidden = !consentBad;
consent.setAttribute("aria-invalid", consentBad ? "true" : "false");
if (consentBad && !firstBad) firstBad = consent;
if (firstBad) { firstBad.focus(); return; }
const btn = form.querySelector(".submit");
btn.disabled = true;
btn.textContent = "Sending…";
try {
// replace with your own endpoint
await new Promise((r) => setTimeout(r, 700));
form.reset();
status.textContent = "Thanks — we will reply within two working days.";
status.className = "status ok";
} catch {
status.textContent = "Something went wrong. Please email hello@example.com instead.";
status.className = "status bad";
} finally {
btn.disabled = false;
btn.textContent = "Send message";
}
});How it works
Every input has a real label. Not a placeholder standing in for one. Placeholders disappear the moment someone starts typing, which means anyone who is interrupted loses the context, and low-contrast placeholder text fails contrast requirements in most implementations. Labels also give you a much larger click target, since clicking a label focuses its field.
novalidate is deliberate. It switches off the browser's own bubble messages so you control the wording and the styling. The required and type="email" attributes stay on the fields — they still inform assistive technology and mobile keyboards.
Validation timing matters. Validating on every keystroke tells someone their email is invalid while they are still typing the first letter. This validates on blur, and then re-validates on input only for a field that has already failed — so the error clears as soon as they fix it, without ever appearing prematurely.
Focus moves to the first invalid field on a failed submit. Without that, a keyboard or screen reader user has no idea which of six fields is the problem.
The honeypot catches most bots at zero cost to real users. Bots fill in every field they find; the field is positioned off-screen, excluded from the tab order and hidden from assistive technology. If it comes back filled, the submission is silently discarded — telling a bot it failed only helps it improve.
Accessibility notes
aria-describedby links each field to its hint and error text, so a screen reader reads the error along with the field rather than leaving it stranded.
aria-invalid marks the field itself as failing, and doubles as the CSS hook for the red border — so the visual and programmatic states can never drift apart.
The status message uses role="status", which announces changes politely without interrupting whatever the user is doing.
Errors are never signalled by colour alone: each has an exclamation mark and a sentence explaining what to fix.
Making it yours
Replace the fake delay in the submit handler with a real request. For a static site, Formspree, Netlify Forms and Basin all accept a plain POST and need no server of your own.
The autocomplete attributes are worth keeping and extending. name, email, tel and street-address let browsers fill forms in one tap, which measurably improves completion on mobile.
Related templates
Login form
A sign-in card with a show-password toggle and a single generic error.
HTMLCSSJavaScriptNewsletter signup
An inline email capture with validation and a success state, in three layouts.
HTMLCSSJavaScriptMulti-step form
A three-step form with a progress indicator, per-step validation and a review screen.
HTMLCSSJavaScriptToggle switch
A switch built on a real checkbox, with sizes, labels and a disabled state.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.