Log in

JavaScript contact form

Twenty-odd lines of vanilla JavaScript that submit the form without leaving the page, disable the button while it is in flight, and say something useful when it fails. No library, no build step, and the form still works if the script never loads.

The rendered result. The code below is the source — it is not run inside this preview.

Replace YOUR_FORM_ID with a form of your own and this sends to your inbox.

Create your form
contact.html
<form id="contact" action="https://submit.formpost.ai/YOUR_FORM_ID" method="POST">
  <h2>Contact us</h2>

  <label for="name">Name</label>
  <input id="name" type="text" name="name" required>

  <label for="email">Email</label>
  <input id="email" type="email" name="email" required>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="4" required></textarea>

  <button type="submit">Send message</button>
  <p id="status" role="status" aria-live="polite"></p>
</form>
contact.js
const form = document.querySelector("#contact");
const status = document.querySelector("#status");
const button = form.querySelector("button");

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  button.disabled = true;
  status.textContent = "Sending...";
  status.className = "";

  try {
    // Post the FormData itself rather than JSON: it carries files, it keeps
    // repeated field names, and it needs no Content-Type header of your own.
    const res = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });

    const result = await res.json();

    if (result.success) {
      form.reset();
      status.textContent = "Thanks — we will be in touch.";
      status.className = "ok";
    } else {
      status.textContent = result.message || "Something went wrong.";
      status.className = "bad";
    }
  } catch {
    // fetch only rejects on a network failure, never on a 4xx or 5xx.
    status.textContent = "Could not reach the server. Please try again.";
    status.className = "bad";
  } finally {
    button.disabled = false;
  }
});
contact.css
body {
  margin: 0;
  padding: 32px 16px;
  background: #fbfbfc;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  color: #18181b;
}

form {
  display: flex;
  flex-direction: column;
  max-width: 420px;
  margin: 0 auto;
}

h2 {
  margin: 0 0 18px;
  font-size: 20px;
}

label {
  margin-bottom: 6px;
  font-size: 13px;
  font-weight: 600;
  color: #3f3f46;
}

input,
textarea {
  box-sizing: border-box;
  font: inherit;
  margin-bottom: 16px;
  padding: 10px 12px;
  color: #18181b;
  background: #fff;
  border: 1px solid #d4d4d8;
  border-radius: 8px;
}

input:focus,
textarea:focus {
  border-color: #2563eb;
  outline: 2px solid rgba(37, 99, 235, 0.2);
}

textarea {
  resize: vertical;
}

button {
  padding: 11px 16px;
  font: inherit;
  font-weight: 600;
  color: #fff;
  background: #2563eb;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

button:disabled {
  background: #93b4f5;
  cursor: default;
}

/* The status line, shared by all three JavaScript versions. Keyed on the role
   rather than a class, so it styles the element that is already announcing
   itself to a screen reader. */
[role="status"] {
  margin: 14px 0 0;
  font-size: 14px;
  text-align: center;
}

[role="status"]:empty {
  display: none;
}

[role="status"].ok {
  color: #16a34a;
}

[role="status"].bad {
  color: #dc2626;
}

Progressive enhancement, for free

The action and method stay on the form element even though JavaScript is going to intercept the submit. That one decision means the form keeps working if the script fails to load, throws on an unrelated line, or is blocked by an extension. The handler reads form.action rather than repeating the URL, so there is only ever one copy of it.

Post FormData, not JSON

new FormData(form) gives you exactly what the browser would have sent. Converting it with Object.fromEntries feels tidier and quietly breaks two things: file inputs vanish, and repeated field names (checkbox groups, multi-selects) collapse to their last value. Posting the FormData directly also means you must not set Content-Type — the browser needs to generate the multipart boundary itself.

const res = await fetch(form.action, {
  method: "POST",
  headers: { Accept: "application/json" },
  body: new FormData(form),
});

The Accept header is what changes the response from a redirect into JSON. Without it the endpoint answers the way it would for a plain form post, and your fetch follows a redirect it has no use for.

fetch does not reject on 4xx

This catches people constantly. A fetch promise only rejects on a network-level failure — DNS, offline, connection refused. A 422 or a 500 resolves normally, so the try/catch here is for the network case and the result.success check is for everything else. Handle both or you will report success for a submission that was rejected.

Say something, and say it out loud

  • role="status" with aria-live="polite" makes the result announced by a screen reader without stealing focus.
  • Disable the button while the request is in flight. Double submissions are otherwise routine on a slow connection.
  • Re-enable it in a finally block, so a failure does not leave the form permanently dead.
  • Reset the form only on success. Clearing somebody's message and then telling them it failed is the worst possible order.

Questions about this contact form

How do I submit a contact form without reloading the page?
Call event.preventDefault() in a submit listener, then post the form's FormData with fetch and an Accept: application/json header. The endpoint answers with JSON instead of redirecting, and the visitor never leaves.
Why does my fetch succeed when the submission was rejected?
fetch only rejects on network failures — a 422 or 500 resolves like any other response. Check the parsed body's success field as well as catching network errors.
Should I send JSON or FormData?
FormData, unless you have a reason not to. It carries file uploads, preserves repeated field names, and needs no Content-Type header. JSON is fine for a simple three-field form and breaks the moment you add an upload.
Do I still need the action attribute if JavaScript handles the submit?
Keep it. It is your fallback when the script fails, and the handler can read form.action instead of hard-coding the URL a second time.

Related examples

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.