A contact form is three parts: HTML for the fields, CSS for how it looks, and an action that points at something which receives the submission. The first two you can copy straight off this page. The third is the part almost every tutorial leaves out, and it's the only reason a message ever reaches your inbox.
HTML and CSS can't send email. They never could. So the code below is a complete, styled, validating form, and then the honest answer to the question every "contact form html css" tutorial dodges: where does the data go.
If you want the field-by-field reference alongside the copy-paste version, the HTML contact form guide has it. Otherwise, start here.
The whole form, copy and paste
Semantic markup first, styling second. Every field has a <label> tied to it with for and id, and every field has a name, because the browser only sends fields that have one. An input with just an id submits nothing.
<form action="PASTE_YOUR_ENDPOINT_HERE" method="post" class="contact-form">
<h2>Send me a message</h2>
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="name" required>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<button type="submit">Send message</button>
</form>That's the structure. It works with no CSS and no JavaScript at all, which is the point of building on the native <form>: if a stylesheet or a script fails to load, the form still submits.
Make it look like something
The markup above renders as stacked browser defaults. This is the CSS I'd reach for: a single grid, generous padding, a visible focus ring, and a real hover state on the button. No framework.
.contact-form {
display: grid;
gap: 0.5rem;
max-width: 28rem;
margin: 0 auto;
padding: 2rem;
font: 1rem/1.5 system-ui, sans-serif;
border: 1px solid #e3e6ee;
border-radius: 12px;
}
.contact-form h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.contact-form label {
font-size: 0.875rem;
font-weight: 600;
}
.contact-form input,
.contact-form textarea {
padding: 0.625rem 0.75rem;
font: inherit;
border: 1px solid #c8cdda;
border-radius: 8px;
}
.contact-form input:focus,
.contact-form textarea:focus {
outline: 2px solid #4f46e5;
outline-offset: 1px;
border-color: transparent;
}
.contact-form button {
margin-top: 0.5rem;
padding: 0.7rem 1rem;
font: inherit;
font-weight: 600;
color: #fff;
background: #4f46e5;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.contact-form button:hover {
background: #4338ca;
}Two details worth keeping. font: inherit on the inputs stops browsers from rendering form controls in their own tiny default typeface. And the visible :focus outline is how keyboard users know where they are; remove it without a replacement and you break the form for them.
Validation without JavaScript
You get most of it for free. required blocks an empty submit, type="email" rejects text that isn't an address, and both throw the browser's own error bubble with zero code. The MDN client-side validation guide covers the full set, including pattern for a custom rule and minlength for a message that has to say more than "hi".
The one thing to get right is the styling, and this is where most tutorials hand you a bug. They style invalid fields with :invalid:
/* Wrong: yells at the visitor before they've typed a thing */
.contact-form input:invalid {
border-color: #dc2626;
}An empty required field is invalid the instant the page loads, so every field is red before anyone has touched it. I shipped this exact bug once. The whole form glowed red on a fresh page, and the only report I got read "your contact form looks broken," which took me longer to reproduce than to fix.

Use :user-invalid instead. It matches only after the visitor has interacted with the field or tried to submit, so the form stays quiet until there's an actual mistake to point at:
/* Right: only after the visitor has interacted with the field */
.contact-form input:user-invalid,
.contact-form textarea:user-invalid {
border-color: #dc2626;
outline-color: #dc2626;
}It's been in every major browser since late 2023, so unless you're supporting something ancient, reach for it directly. This is real validation for a contact form, and it took no JavaScript.
Where the data actually goes
This is the part the ranking tutorials skip. W3Schools ends its contact-form page with action="action_page.php" and points you to their HTML form tutorial for the rest. The GeeksforGeeks version bolts on a PHP script. Others just write "add your backend here." You're left with a form that looks finished and delivers nothing.
The action attribute is the whole story. It's the URL the browser POSTs the fields to, and that receiver is what turns a submission into an email. Three things people put there, and only one keeps the no-backend promise:
action="mailto:you@site.com" does not send email. It tries to open the visitor's desktop mail client with the fields mangled into a draft, and on a phone or a machine with no mail app configured it does nothing at all. I wrote up why the mailto route fails and what to use instead in more detail.
A PHP script works, but it's the backend you were trying to skip: a PHP server to run, and then SPF, DKIM, and DMARC to configure, or your notifications land in spam. That's not a contact form tutorial anymore, it's a deliverability project.
A hosted form endpoint is the version that keeps the "no backend" promise. You paste one URL into the action, and the service receives the POST, stores it, filters spam, and emails you from a sender that actually arrives:
<form action="https://api.formplume.com/f/your-form-id" method="post" class="contact-form">That's the only line that changes. The HTML, the CSS, and the validation above stay exactly as they are. I build Form Plume, so read that as the pitch it is, but the pattern is the same across Formspree, Web3Forms, and the rest: the form is yours, the receiver is theirs. Where Form Plume differs is the free tier stores 500 submissions a month in a dashboard rather than only relaying them to email, so a delivery hiccup never loses a message.
Inline states with JavaScript, when you want them
Everything above submits by navigating to a new page. If you'd rather keep the visitor in place and show an inline "sent" message, that's the one job JavaScript is actually for here. Add a status line:
<p id="status" role="status"></p>Then intercept the submit and send it with fetch. Asking for JSON tells Form Plume to reply with data instead of a redirect:
const form = document.querySelector(".contact-form");
const status = document.getElementById("status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.textContent = "Sending...";
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
status.textContent = response.ok
? "Thanks, I got your message."
: "Something went wrong. Please try again.";
});One thing that trips people up: native validation still runs before this handler fires. The browser blocks an invalid form and shows its bubbles, so an empty or malformed field never reaches the fetch. If you want your own wording instead of the browser's default "Please fill in this field," that's what the Constraint Validation API is for. Read validity to see what failed and call setCustomValidity to replace the message:
const email = form.querySelector("#email");
email.addEventListener("input", () => {
email.setCustomValidity(
email.validity.typeMismatch ? "That doesn't look like an email address." : ""
);
});Set it back to an empty string when the value is valid, or the field stays stuck as invalid. For the same pattern written as a component, there are guides for a JavaScript contact form and a React contact form.
Keep this as progressive enhancement. The <form action method="post"> underneath still works if the script fails, which is exactly the fallback you want.
Before you ship it
The form is done. These three are what stand between "done" and "actually receiving mail":
- Send one real submission from the deployed site and confirm it lands in your inbox. A green request in the Network tab proves the POST left the browser, not that the email arrived. If it doesn't show up, my contact form debugging checklist walks the usual causes.
- Add a honeypot field before it's public, or spam bots find it within days. Most hosted endpoints include one; if you're rolling your own receiver, you're building this too.
- Generate the markup instead of hand-typing it if you want the field names, labels, and endpoint wired correctly the first time. The HTML form generator outputs the whole thing, and the contact form checker grades a form you already have.
