# HTML/CSS Contact Form That Actually Emails You (2026)
A complete HTML and CSS contact form you can copy, paste, and style, with native and JavaScript validation, that actually emails you without a backend.
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](/html-contact-form) has it. Otherwise, start here.
## The whole form, copy and paste
Semantic markup first, styling second. Every field has a `` 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.
```html
```
That's the structure. It works with no CSS and no JavaScript at all, which is the point of building on the native ``: 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.
```css
.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](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation) 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`:
```css
/* 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.

*Your visitor, to a form that turned every field red before they typed a thing.*
Use [`:user-invalid`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/: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:
```css
/* 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](/blog/html-form-to-email-without-backend) 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:
```html
```
That's the only line that changes. The HTML, the CSS, and the validation above stay exactly as they are. I build [Form Plume](/html-contact-form), 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:
```html
```
Then intercept the submit and send it with `fetch`. Asking for JSON tells Form Plume to reply with data instead of a redirect:
```js
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:
```js
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](/javascript-contact-form) and a [React contact form](/react-contact-form).
Keep this as progressive enhancement. The ` ` 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](/blog/why-is-my-contact-form-not-working) 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](/tools/html-form-generator) outputs the whole thing, and the [contact form checker](/tools/contact-form-checker) grades a form you already have.