React contact form
A real React component: controlled fields with useState, a submit handler that posts FormData to the endpoint with fetch, and idle, sending, sent and error states. Keeping the <form action> means it still submits if the JavaScript never runs. In a Next.js or Vite app you would drop the three CDN scripts and paste the ContactForm component into a file; here it runs standalone so the preview is the paste. See the docs for the Server Action version.
React · HTML · CSS
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Contact us</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone@7.26.4/babel.min.js"></script>
<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; text-align: center; }
.status.error { color: #b91c1c; }
</style>
</head>
<body>
<div id="root"></div>
<!-- Three CDN scripts so the file runs with no build step. In a Next.js or
Vite app, delete these and keep the ContactForm component below. -->
<script type="text/babel" data-presets="react">
/* @jsxRuntime classic — compile JSX to React.createElement so it runs
against the UMD global above, with no module import to resolve. */
const { useState } = React
const ENDPOINT = 'https://app.portform.co/api/submit'
const ACCESS_KEY = 'YOUR_ACCESS_KEY'
function ContactForm() {
// One object for the fields, so adding a field is one line here and one
// input below, nothing else to wire.
const [values, setValues] = useState({ name: '', email: '', message: '' })
const [state, setState] = useState('idle') // idle · sending · sent · error
const [error, setError] = useState('')
const set = (key) => (event) =>
setValues((prev) => ({ ...prev, [key]: event.target.value }))
async function onSubmit(event) {
event.preventDefault()
setState('sending')
setError('')
try {
const body = new FormData()
Object.entries(values).forEach(([key, value]) => body.append(key, value))
body.append('access_key', ACCESS_KEY)
const res = await fetch(ENDPOINT, { method: 'POST', body })
const data = await res.json()
if (!data.success) throw new Error(data.message || 'Something went wrong')
setState('sent')
} catch (err) {
setError(err.message)
setState('error')
}
}
if (state === 'sent') {
return (
<div className="card">
<h1>Thanks</h1>
<p className="sub">We got your message and will reply shortly.</p>
</div>
)
}
return (
<form className="card" action={ENDPOINT} method="POST" onSubmit={onSubmit}>
<h1>Get in touch</h1>
<p className="sub">Fill in the form and we'll reply by email.</p>
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="checkbox" name="botcheck" className="hp" tabIndex={-1} autoComplete="off" />
<div className="field">
<label htmlFor="name">Name</label>
<input id="name" type="text" name="name" autoComplete="name"
value={values.name} onChange={set('name')} required />
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" autoComplete="email"
value={values.email} onChange={set('email')} required />
</div>
<div className="field">
<label htmlFor="message">Message</label>
<textarea id="message" name="message"
value={values.message} onChange={set('message')} required />
</div>
<button type="submit" disabled={state === 'sending'}>
{state === 'sending' ? 'Sending…' : 'Send message'}
</button>
{state === 'error' && <p className="status error">{error}</p>}
</form>
)
}
ReactDOM.createRoot(document.getElementById('root')).render(<ContactForm />)
</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
- 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
- Ajax contact formHTML · CSS · JavaScript
- 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