Ajax contact form
The same form, submitted with fetch so the page never navigates. It keeps the action attribute, which means it still works if the script fails to load. The browser just falls back to a normal POST. That fallback is the reason to copy this one rather than write your own.
HTML · CSS · JavaScript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Contact us</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 32px 16px;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #f4f4f5;
color: #18181b;
}
.card {
max-width: 460px;
margin: 0 auto;
padding: 28px;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 12px;
}
h1 { margin: 0 0 6px; font-size: 20px; }
.sub { margin: 0 0 22px; font-size: 14px; color: #71717a; }
.field { margin-bottom: 16px; }
label { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; }
input, textarea, select {
width: 100%;
padding: 10px 12px;
font: inherit;
font-size: 14px;
color: #18181b;
background: #fff;
border: 1px solid #d4d4d8;
border-radius: 8px;
}
input:focus, textarea:focus, select:focus {
outline: 2px solid #18181b;
outline-offset: 1px;
border-color: transparent;
}
textarea { min-height: 110px; resize: vertical; }
button {
width: 100%;
padding: 11px 16px;
font: inherit;
font-size: 14px;
font-weight: 500;
color: #fff;
background: #18181b;
border: 0;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #3f3f46; }
button:active { transform: scale(0.99); }
button[disabled] { opacity: 0.6; cursor: default; }
/* Hidden from people, filled in by bots. Never remove it. */
.hp { display: none; }
.status { margin: 12px 0 0; font-size: 13px; color: #71717a; text-align: center; }
.status.error { color: #b91c1c; }
</style>
</head>
<body>
<form id="form" class="card" action="https://app.portform.co/api/submit" method="POST">
<h1>Get in touch</h1>
<p class="sub">Fill in the form and we'll reply by email.</p>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="checkbox" name="botcheck" class="hp" tabindex="-1" autocomplete="off" />
<div class="field">
<label for="name">Name</label>
<input id="name" type="text" name="name" autocomplete="name" required />
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" name="email" autocomplete="email" required />
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
</div>
<button type="submit">Send message</button>
<p id="status" class="status" role="status"></p>
</form>
<script>
const form = document.getElementById('form')
const status = document.getElementById('status')
form.addEventListener('submit', async (event) => {
event.preventDefault()
const button = form.querySelector('button')
button.disabled = true
status.textContent = 'Sending…'
try {
// FormData straight through — the browser sets the content type.
const res = await fetch(form.action, { method: 'POST', body: new FormData(form) })
const data = await res.json()
if (!data.success) throw new Error(data.message || 'Something went wrong')
form.innerHTML = '<h1>Thanks</h1><p class="sub">We got your message and will reply shortly.</p>'
} catch (error) {
status.textContent = error.message
status.classList.add('error')
button.disabled = false
}
})
</script>
</body>
</html>Replace YOUR_ACCESS_KEY with the access key from your dashboard. Create one free. The endpoint and the honeypot are already in place.
Useful for
- Waitlist formsCollect early signups before you have a product.
- Contact formsThe classic name / email / message, delivered to your inbox.
- Lead captureRoute new leads to your inbox, Slack, or CRM.
- Demo requestsQualify and route demo requests to sales.
The part behind every one of these is a form backend. This template posts to a PortForm form endpoint, which stores each submission and sends it to your email; the docs cover the fields and responses.
Other templates
- Basic HTML contact formHTML · CSS
- JavaScript form validationHTML · CSS · JavaScript
- Multi-column contact formHTML · CSS
- Masked phone inputHTML · CSS · JavaScript
- Contact form with dark modeHTML · CSS
- Raw contact form (no CSS)HTML
- Multi-step NPS surveyHTML · CSS · JavaScript
- Multi-step survey formVue · HTML · CSS
- Contact form popupTailwindCSS · Alpine.js · HTML
- Popover feedback widgetTailwindCSS · Alpine.js · HTML
- File upload formHTML · CSS
- JavaScript file upload formHTML · CSS · JavaScript
- React contact formReact · HTML · CSS