splitforms.com
All articles/ TUTORIALS22 MIN READPublished May 5, 2026

HTML contact form code: 12 free templates you can copy-paste in 2026

12 ready-to-paste HTML contact form templates — minimal, accessible, styled, multi-step, file upload, popup. Free, no signup, with backend wired in 60 seconds via splitforms.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

The minimum viable HTML contact form

An HTML contact form has three structural pieces: a <form> element that wraps everything, one or more <input> or <textarea> fields where the user types, and a <button type="submit">that triggers the submission. That's the entire contract the browser cares about.

The three attributes that change behavior on the <form> tag itself are action, method, and enctype. action is the URL the browser POSTs the form data to. method is almost always POST for contact forms (GET puts the data in the URL bar, which leaks PII into server logs). enctype is application/x-www-form-urlencoded by default, but you have to switch it to multipart/form-data the moment you accept file uploads — otherwise the browser silently drops the file.

Here's the bare minimum HTML for a contact form:

<form>
  <label>Name <input name="name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Message <textarea name="message" required></textarea></label>
  <button type="submit">Send</button>
</form>

Drop that into an HTML file, open it in a browser, and the form renders correctly — but clicking Send does nothing useful. With no action, the browser POSTs back to the current URL, the page reloads, and the data evaporates. There's no email, no database row, no notification. The form is visually present but functionally broken.

To make it actually work end-to-end, point action at a backend that knows how to receive form submissions, and add a hidden access_key that tells the backend which account the submission belongs to:

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
  <label>Name <input name="name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Message <textarea name="message" required></textarea></label>
  <button type="submit">Send</button>
</form>

Get a free access key at /free-contact-form — no credit card, no signup form to fill out. Paste your key into the value attribute of the hidden input above and the form is live. Every template below uses the same pattern; only the visible fields and styling change.

For deeper coverage of the form action attribute itself (relative URLs, formaction overrides, GET-vs-POST trade-offs, mailto pitfalls), see the HTML form action complete guide.

One small but important detail: the splitforms backend reads the visitor's submitted email address out of any field named email and uses it as the reply-to header on the notification email it sends you. So when a notification lands in your inbox and you click reply, your reply goes back to the visitor — not to the splitforms address. This is the reason the field has to be named email, not your_email or contact_email. Other field names work for any other input.

12 ready-to-paste HTML contact form templates

Each template below is real, runnable HTML — paste it into any .html file, swap YOUR_ACCESS_KEY for your splitforms key, and it works. The patterns scale from a 12-line minimal form to a multi-step flow with conditional fields, so pick the one that matches your design and budget for complexity.

1. Minimal contact form

Use this when you want the smallest possible footprint — a personal site, a documentation page, a footer contact link, a 404 page's "report this" form. No CSS dependencies, no JavaScript, no third-party libraries. Three fields, one submit button, one hidden access key. It inherits whatever typography and spacing your page's base CSS already provides.

The required attribute on each input forces the browser to block submission if the field is empty and surface its native validation bubble. type="email" on the email input adds basic syntax validation (it requires an @ and a domain). The nameattribute on every field is what shows up in the splitforms dashboard and email — pick names you'll recognize when 200 submissions deep.

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

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

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

  <label for="message">Your message</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <button type="submit">Send message</button>
</form>

Customize: add fields by inserting more <label> + <input> pairs; every named field appears in the dashboard automatically. Change the action if you migrate backends. Reorder labels and inputs without changing behavior. The full free HTML contact form template page has a styled variant.

2. Bootstrap 5 contact form

Use this when your site already loads Bootstrap 5 (most marketing sites built on a CMS theme do). The form uses Bootstrap's form-control, form-label, was-validated, and invalid-feedback classes for native-looking validation states. The needs-validation + novalidate combo lets Bootstrap's JS handle the visual feedback while the browser still enforces required server-side via splitforms.

Bootstrap's grid (row + col-md-6) lets you put the name and email side-by-side on tablet and desktop, stacking on mobile. The submit button uses Bootstrap's btn-primary for the default brand color — override the CSS variable --bs-primaryon the parent element if your brand color isn't Bootstrap blue.

<!-- Requires Bootstrap 5 CSS + JS bundle on the page -->
<form action="https://splitforms.com/api/submit" method="POST" class="needs-validation" novalidate>
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

  <div class="row g-3">
    <div class="col-md-6">
      <label for="bs-name" class="form-label">Name</label>
      <input id="bs-name" name="name" type="text" class="form-control" required>
      <div class="invalid-feedback">Please enter your name.</div>
    </div>
    <div class="col-md-6">
      <label for="bs-email" class="form-label">Email</label>
      <input id="bs-email" name="email" type="email" class="form-control" required>
      <div class="invalid-feedback">A valid email is required.</div>
    </div>
    <div class="col-12">
      <label for="bs-subject" class="form-label">Subject</label>
      <input id="bs-subject" name="subject" type="text" class="form-control">
    </div>
    <div class="col-12">
      <label for="bs-message" class="form-label">Message</label>
      <textarea id="bs-message" name="message" class="form-control" rows="5" required></textarea>
      <div class="invalid-feedback">A message is required.</div>
    </div>
    <div class="col-12">
      <button type="submit" class="btn btn-primary">Send message</button>
    </div>
  </div>
</form>

<script>
  // Toggle Bootstrap's was-validated state on submit attempt.
  document.querySelectorAll('.needs-validation').forEach((form) =&gt; {
    form.addEventListener('submit', (e) =&gt; {
      if (!form.checkValidity()) { e.preventDefault(); e.stopPropagation(); }
      form.classList.add('was-validated');
    });
  });
</script>

Customize: swap btn-primary for btn-dark, btn-success, or your custom button class. Add Bootstrap's form-floating wrapper around any input pair to switch from top labels to floating labels.

3. Tailwind CSS contact form

Use this when your site is built on Tailwind (most modern Next.js, Astro, and SvelteKit sites are). Utility classes are inlined, no external CSS, dark mode via the dark: prefix, and accessible focus rings via focus:ring-2. The form scales cleanly from 320px-wide phones to 1280px+ desktops without breakpoints.

The peer + peer-invalid pattern surfaces a red border when the user blurs an invalid input — no JavaScript required. accent-coloron the page's root tints native checkboxes and radios to match your brand. Dark mode flips backgrounds, text colors, and border tones in one pass.

<!-- Requires Tailwind CSS on the page; dark: classes assume class-based dark mode -->
<form action="https://splitforms.com/api/submit" method="POST" class="space-y-4 max-w-lg mx-auto">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

  <div>
    <label for="tw-name" class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">Name</label>
    <input id="tw-name" name="name" type="text" required
      class="peer w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900
             placeholder:text-zinc-400 focus:border-indigo-500 focus:outline-none focus:ring-2
             focus:ring-indigo-500/30 invalid:border-red-500
             dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100">
  </div>

  <div>
    <label for="tw-email" class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">Email</label>
    <input id="tw-email" name="email" type="email" required
      class="peer w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900
             placeholder:text-zinc-400 focus:border-indigo-500 focus:outline-none focus:ring-2
             focus:ring-indigo-500/30 invalid:border-red-500
             dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100">
  </div>

  <div>
    <label for="tw-message" class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-1">Message</label>
    <textarea id="tw-message" name="message" rows="5" required
      class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900
             placeholder:text-zinc-400 focus:border-indigo-500 focus:outline-none focus:ring-2
             focus:ring-indigo-500/30
             dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"></textarea>
  </div>

  <button type="submit"
    class="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white
           shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/40">
    Send message
  </button>
</form>

Customize: swap indigo- for any Tailwind palette (emerald-, rose-, slate-). For more validation states, see Tailwind CSS form validation.

4. Accessible contact form (WCAG AA)

Use this when accessibility compliance matters — government sites, healthcare, education, large e-commerce. The template covers WCAG 2.1 AA: every input has a programmatic <label>, required fields are marked both visually (the *) and to assistive tech (aria-required="true"), error messages are bound to inputs with aria-describedby, and the submit status is announced via a polite aria-live region.

Focus states use a 2px outline with offset — never outline: none. The visible focus ring is the primary accommodation for keyboard-only users; removing it without a custom replacement is the single most common WCAG failure on contact forms in 2026.

<form action="https://splitforms.com/api/submit" method="POST" novalidate aria-labelledby="contact-heading">
  <h2 id="contact-heading">Contact us</h2>
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

  <p id="form-instructions" class="instructions">
    Fields marked with <span aria-hidden="true">*</span> are required.
  </p>

  <div class="field">
    <label for="a11y-name">
      Name <span aria-hidden="true">*</span>
    </label>
    <input id="a11y-name" name="name" type="text"
           required aria-required="true"
           aria-describedby="a11y-name-err">
    <span id="a11y-name-err" class="error" role="alert"></span>
  </div>

  <div class="field">
    <label for="a11y-email">
      Email <span aria-hidden="true">*</span>
    </label>
    <input id="a11y-email" name="email" type="email"
           required aria-required="true"
           autocomplete="email"
           aria-describedby="a11y-email-hint a11y-email-err">
    <span id="a11y-email-hint" class="hint">We&#39;ll only use this to reply.</span>
    <span id="a11y-email-err" class="error" role="alert"></span>
  </div>

  <div class="field">
    <label for="a11y-message">
      Message <span aria-hidden="true">*</span>
    </label>
    <textarea id="a11y-message" name="message" rows="5"
              required aria-required="true"
              aria-describedby="a11y-message-err"></textarea>
    <span id="a11y-message-err" class="error" role="alert"></span>
  </div>

  <button type="submit">Send message</button>
  <p id="submit-status" aria-live="polite" class="visually-hidden"></p>
</form>

<style>
  :where(input, textarea):focus-visible {
    outline: 2px solid #4f46e5;
    outline-offset: 2px;
  }
  .visually-hidden {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; clip: rect(0 0 0 0); overflow: hidden;
  }
  .error[role="alert"]:empty { display: none; }
</style>

Customize: wire client-side validation to write into each .error span on invalid events; leave the markup as-is and screen readers will announce the changes automatically because of role="alert".

5. Contact form with file upload

Use this when you need a screenshot, a resume, a logo, or any binary attachment. The single most common bug here is forgetting enctype="multipart/form-data" on the <form> — without it, the browser sends the filename as a string and silently drops the actual file bytes. Always set enctype on file-upload forms.

The accept attribute is a soft hint to the file picker — it filters which files appear in the chooser dialog but does not prevent malicious uploads. Always validate file type and size on the server (splitforms enforces a 5MB-per-file default and rejects executables automatically).

<form action="https://splitforms.com/api/submit"
      method="POST"
      enctype="multipart/form-data">

  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

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

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

  <label for="fu-message">What do you need help with?</label>
  <textarea id="fu-message" name="message" rows="4" required></textarea>

  <label for="fu-file">Attach a screenshot (optional)</label>
  <input id="fu-file" name="screenshot" type="file"
         accept="image/png,image/jpeg,image/webp,application/pdf">
  <p class="hint">PNG, JPG, WebP or PDF up to 5MB.</p>

  <button type="submit">Send with attachment</button>
</form>

<style>
  input[type="file"] {
    padding: 0.5rem;
    border: 1px dashed #c9c9cf;
    border-radius: 8px;
    background: #fafaf7;
    cursor: pointer;
  }
  input[type="file"]:focus-visible {
    outline: 2px solid #4f46e5;
    outline-offset: 2px;
  }
</style>

Customize: add multiple to the file input to allow multiple attachments. Tighten accept to image/* for image-only uploads, or to a single MIME type for stricter filtering.

6. Multi-step contact form (vanilla JS)

Use this when the form has more than ~6 fields — a long single-screen form has measurably worse completion rates than the same fields split across two or three steps. The pattern below uses <fieldset> elements as steps, a progress bar that updates with each step, Previous/Next buttons that toggle visibility, and a final submit on the last step. No libraries.

The data-step attribute on each fieldset and the JS state machine that flips the hidden attribute keeps the entire form's state inside a single <form>, which means the final POST contains every field across every step. No localStorage juggling, no progressive backend writes — splitforms gets one clean submission with all fields in it.

<form id="multi" action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

  <progress id="ms-progress" value="1" max="3" aria-label="Form progress"></progress>

  <fieldset data-step="1">
    <legend>Step 1: about you</legend>
    <label>Name <input name="name" required></label>
    <label>Email <input name="email" type="email" required></label>
    <button type="button" data-next>Next →</button>
  </fieldset>

  <fieldset data-step="2" hidden>
    <legend>Step 2: your project</legend>
    <label>Company <input name="company"></label>
    <label>Budget
      <select name="budget" required>
        <option value="">Choose…</option>
        <option>Under $5k</option>
        <option>$5k–25k</option>
        <option>$25k+</option>
      </select>
    </label>
    <button type="button" data-prev>← Back</button>
    <button type="button" data-next>Next →</button>
  </fieldset>

  <fieldset data-step="3" hidden>
    <legend>Step 3: details</legend>
    <label>Message <textarea name="message" rows="5" required></textarea></label>
    <button type="button" data-prev>← Back</button>
    <button type="submit">Send message</button>
  </fieldset>
</form>

<script>
  const form = document.getElementById('multi');
  const progress = document.getElementById('ms-progress');
  const steps = form.querySelectorAll('fieldset[data-step]');
  let current = 1;

  const show = (n) =&gt; {
    steps.forEach((fs) =&gt; {
      fs.hidden = Number(fs.dataset.step) !== n;
    });
    progress.value = n;
    current = n;
  };

  form.addEventListener('click', (e) =&gt; {
    if (e.target.matches('[data-next]')) {
      const fs = steps[current - 1];
      if (!fs.checkValidity()) { fs.reportValidity(); return; }
      show(current + 1);
    } else if (e.target.matches('[data-prev]')) {
      show(current - 1);
    }
  });
</script>

Customize: add steps by appending another <fieldset data-step="N">; the script auto-detects the count via steps.length. Style the <progress> element with CSS to match your brand. Full styled version: multi-step form template.

8. Contact form with honeypot (reCAPTCHA replacement)

Use this when you don't want to load Google reCAPTCHA but still need to block bots. A honeypot is a hidden field that bots fill in (because they fill every <input> they find) but humans never see. splitforms drops any submission with a non-empty honeypot value automatically — no server config, no API key.

The four critical attributes on a honeypot field: tabindex="-1" so keyboard users skip it, autocomplete="off" so password managers don't auto-fill it, aria-hidden="true" so screen readers ignore it, and inline CSS that pushes it off-screen. Don't use display: none — some bots specifically detect that and skip the field.

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

  <!-- Honeypot. Real users never fill this in. -->
  <div aria-hidden="true" style="position:absolute;left:-9999px;height:0;overflow:hidden">
    <label for="hp-website">Don&#39;t fill this out if you&#39;re human:</label>
    <input id="hp-website" name="website" type="text"
           tabindex="-1" autocomplete="off" value="">
  </div>

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

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

  <label for="hp-message">Message</label>
  <textarea id="hp-message" name="message" rows="5" required></textarea>

  <button type="submit">Send message</button>
</form>

Customize: rename website to anything plausible — fax_number, company_url, nickname. Bots that specifically skip fields named honeypot or botcheck will fill in a plausible-sounding name. Read honeypot vs reCAPTCHA for benchmark numbers.

9. Contact form with phone field + country code dropdown

Use this when you serve international users and want a clean phone capture without pulling in a 50KB intl-tel-input library. The trick is splitting the country code into its own <select> and the local number into a separate type="tel"input. The browser's tel keypad pops up on mobile, and you don't have to parse free-form phone strings on the backend.

The pattern attribute on the local number input enforces a basic numeric-with-spaces format. inputmode="tel" nudges mobile browsers to show the dialer keypad instead of the QWERTY keyboard.

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

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

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

  <fieldset class="phone-group">
    <legend>Phone</legend>
    <label for="ph-cc" class="visually-hidden">Country code</label>
    <select id="ph-cc" name="country_code" required>
      <option value="+1">+1 (US/CA)</option>
      <option value="+44">+44 (UK)</option>
      <option value="+33">+33 (FR)</option>
      <option value="+49">+49 (DE)</option>
      <option value="+34">+34 (ES)</option>
      <option value="+39">+39 (IT)</option>
      <option value="+31">+31 (NL)</option>
      <option value="+46">+46 (SE)</option>
      <option value="+47">+47 (NO)</option>
      <option value="+61">+61 (AU)</option>
      <option value="+64">+64 (NZ)</option>
      <option value="+81">+81 (JP)</option>
      <option value="+82">+82 (KR)</option>
      <option value="+91">+91 (IN)</option>
      <option value="+55">+55 (BR)</option>
      <option value="+52">+52 (MX)</option>
    </select>

    <label for="ph-num" class="visually-hidden">Phone number</label>
    <input id="ph-num" name="phone" type="tel"
           inputmode="tel"
           pattern="[0-9 \-]{6,15}"
           placeholder="555 123 4567" required>
  </fieldset>

  <label for="ph-message">How can we help?</label>
  <textarea id="ph-message" name="message" rows="4" required></textarea>

  <button type="submit">Send message</button>
</form>

<style>
  .phone-group { display: flex; gap: 8px; border: none; padding: 0; }
  .phone-group select { flex: 0 0 auto; }
  .phone-group input { flex: 1; }
  .visually-hidden {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; clip: rect(0 0 0 0); overflow: hidden;
  }
</style>

Customize: the country list is intentionally short — add the codes for your top markets only. For 200+ codes, use the intl-tel-input library, but expect the page weight to increase.

10. Quote request form (conditional fields)

Use this when the fields you want to collect depend on what the user picks first — "Service type" → different follow-up fields per service. The pattern is a parent <select> that toggles the hidden attribute on dependent <fieldset> elements. Pure HTML + 10 lines of JS.

Hidden fields are still part of the form, but their required attribute does not block submission while the fieldset is hidden because the disabled attribute on the fieldset removes the inputs from the submitted FormData. The show() helper in the script flips both hidden and disabled.

<form id="quote" action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

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

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

  <label for="q-service">What do you need a quote for?</label>
  <select id="q-service" name="service" required>
    <option value="">Choose…</option>
    <option value="web">Website build</option>
    <option value="brand">Brand identity</option>
    <option value="seo">SEO retainer</option>
  </select>

  <fieldset data-service="web" hidden disabled>
    <legend>Website details</legend>
    <label>Pages <input name="pages" type="number" min="1"></label>
    <label>Existing site URL <input name="existing_url" type="url"></label>
  </fieldset>

  <fieldset data-service="brand" hidden disabled>
    <legend>Brand details</legend>
    <label>Industry <input name="industry"></label>
    <label>Logo needed?
      <select name="logo_needed">
        <option>Yes</option>
        <option>No</option>
      </select>
    </label>
  </fieldset>

  <fieldset data-service="seo" hidden disabled>
    <legend>SEO details</legend>
    <label>Monthly traffic <input name="monthly_traffic" type="number"></label>
    <label>Target keywords <textarea name="keywords" rows="2"></textarea></label>
  </fieldset>

  <label for="q-budget">Budget</label>
  <select id="q-budget" name="budget" required>
    <option value="">Choose…</option>
    <option>Under $5k</option>
    <option>$5k–25k</option>
    <option>$25k+</option>
  </select>

  <label for="q-message">Anything else?</label>
  <textarea id="q-message" name="message" rows="3"></textarea>

  <button type="submit">Request quote</button>
</form>

<script>
  const select = document.getElementById('q-service');
  const groups = document.querySelectorAll('fieldset[data-service]');
  select.addEventListener('change', () =&gt; {
    groups.forEach((fs) =&gt; {
      const active = fs.dataset.service === select.value;
      fs.hidden = !active;
      fs.disabled = !active;
    });
  });
</script>

Customize: add another service by appending an <option> to the select and a matching <fieldset data-service="X">.

11. Newsletter signup form

Use this for footer newsletter capture or sidebar opt-ins — a single email field, a GDPR consent checkbox, and a polite success state. The aria-live="polite"region announces the success message to screen readers without stealing focus. The submit handler intercepts the form, posts via fetch so the page doesn't reload, and swaps in the success message.

The GDPR checkbox is requiredby EU regulation if you store the email for later marketing. The consent text must be in plain language — "I agree to receive emails from [Company] and understand I can unsubscribe at any time" is a defensible default; pre-checked boxes are not lawful under GDPR.

<form id="newsletter" action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
  <input type="hidden" name="form_type" value="newsletter">

  <label for="nl-email" class="visually-hidden">Your email</label>
  <input id="nl-email" name="email" type="email"
         placeholder="you@example.com" required autocomplete="email">

  <label class="consent">
    <input type="checkbox" name="gdpr_consent" value="yes" required>
    I agree to receive occasional updates and understand I can unsubscribe anytime.
  </label>

  <button type="submit">Subscribe</button>

  <p id="nl-status" role="status" aria-live="polite"></p>
</form>

<script>
  const form = document.getElementById('newsletter');
  form.addEventListener('submit', async (e) =&gt; {
    e.preventDefault();
    const status = document.getElementById('nl-status');
    status.textContent = 'Subscribing…';
    const r = await fetch(form.action, {
      method: 'POST',
      body: new FormData(form),
      headers: { Accept: 'application/json' },
    });
    if (r.ok) {
      form.reset();
      status.textContent = 'Thanks — check your inbox to confirm.';
    } else {
      status.textContent = 'Something went wrong. Please try again.';
    }
  });
</script>

<style>
  .consent {
    display: flex; gap: 8px; font-size: 13px;
    color: #555; align-items: flex-start; margin: 8px 0 12px;
  }
  .visually-hidden {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; clip: rect(0 0 0 0); overflow: hidden;
  }
</style>

Customize: change form_type to filter newsletter signups separately in the splitforms dashboard. Add a hidden tag field to attribute the source (e.g. footer, sidebar, blog-cta).

12. Booking / consultation form

Use this for "Book a call" or "Schedule a consultation" CTAs without integrating Calendly. The type="datetime-local" input is a native HTML datetime picker — supported in every modern browser since 2020 — that returns an ISO-8601 string the backend can parse without a date library.

The min attribute on the datetime input is set dynamically by the script to "now", preventing users from picking past dates. The step="1800" attribute (1800 seconds = 30 minutes) constrains the time picker to half-hour increments, which matches typical consultation slot sizes.

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">

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

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

  <label for="bk-when">Preferred date and time</label>
  <input id="bk-when" name="preferred_at" type="datetime-local"
         step="1800" required>

  <label for="bk-tz">Time zone</label>
  <select id="bk-tz" name="timezone" required>
    <option value="">Choose…</option>
    <option>America/New_York</option>
    <option>America/Los_Angeles</option>
    <option>Europe/London</option>
    <option>Europe/Berlin</option>
    <option>Asia/Singapore</option>
    <option>Australia/Sydney</option>
  </select>

  <label for="bk-topic">What would you like to discuss?</label>
  <textarea id="bk-topic" name="topic" rows="4" required></textarea>

  <button type="submit">Request booking</button>
</form>

<script>
  // Set the minimum datetime to "now" so users can't pick the past.
  const when = document.getElementById('bk-when');
  const now = new Date();
  now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
  when.min = now.toISOString().slice(0, 16);

  // Pre-select the user's timezone if it's in the list.
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const tzSelect = document.getElementById('bk-tz');
  for (const opt of tzSelect.options) {
    if (opt.value === tz) { tzSelect.value = tz; break; }
  }
</script>

Customize: add a <select> for meeting duration (30/60/90 min). For real calendar availability, integrate Cal.com or Calendly via webhook — see connect splitforms to Zapier.

Adding a backend in 60 seconds (the splitforms way)

Every template above has the same structural backend wiring: the form's action attribute points at https://splitforms.com/api/submit, and a hidden access_keyinput identifies your account. That's the entire integration. The actual setup takes about 60 seconds:

  1. Sign up free at splitforms.com. Use any email — no credit card, no plan picker. The signup screen at /free-contact-form generates an access key on the spot.
  2. Copy the access key. It's a 32-character string that looks like sf_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. Public-key style — safe to ship in HTML.
  3. Drop it into the form's hidden input. Replace YOUR_ACCESS_KEY in any of the templates above with your actual key.
  4. Submit a test form. Open the page, fill in the fields, click submit. The submission appears in your dashboard immediately and arrives in your inbox within 30 seconds.

From there, every submission is automatically: stored in your dashboard with full field history, emailed to you with the user's reply-to address pre-set, screened against the built-in honeypot + spam classifiers, and (optionally) forwarded to Slack, Discord, a webhook, or a Zapier zap. No server, no SMTP credentials, no PHP — your static HTML page now has a real backend.

The free tier covers most personal sites, indie SaaS contact forms, and small-team marketing pages without ever needing an upgrade. If you outgrow it (high-volume signup pages, file uploads over 5MB, custom domain on the success page), the paid plans are linear-priced — no per-seat fees, no "contact sales" tier. The HTML in your form does not change when you upgrade; only the dashboard configuration does. That's deliberate: your repo stays clean and the backend is a runtime concern.

For platform-specific guides see /forms/html, or read how to add a contact form in 60 seconds.

Styling tips for HTML contact forms

The default browser styles for forms are functional but ugly. The good news: in 2026, you don't need a 5KB form-reset CSS file. A modern reset is about a dozen lines and uses native CSS features that didn't exist five years ago.

/* Modern form reset, 2026. Drop this on the page once. */
:root {
  /* Tints native checkbox, radio, range, progress to your brand. */
  accent-color: #4f46e5;
}

input, textarea, select {
  font: inherit;
  width: 100%;
  padding: 0.625rem 0.75rem;
  border: 1px solid #d4d4d8;
  border-radius: 8px;
  background: #fff;
  /* Auto-grow textareas to fit content (Chrome 123+, Safari 17.4+). */
  field-sizing: content;
}

input:focus-visible,
textarea:focus-visible,
select:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
  border-color: transparent;
}

/* Error state — only after the user has interacted with the field. */
input:user-invalid,
textarea:user-invalid {
  border-color: #ef4444;
  background: #fef2f2;
}

button[type="submit"] {
  font: inherit;
  font-weight: 600;
  padding: 0.75rem 1.25rem;
  border: none;
  border-radius: 8px;
  background: #4f46e5;
  color: #fff;
  cursor: pointer;
}

button[type="submit"]:hover { background: #4338ca; }
button[type="submit"]:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}

/* Mobile: bigger tap targets. */
@media (max-width: 480px) {
  input, textarea, select, button { font-size: 16px; }
  /* font-size: 16px on inputs prevents iOS Safari's auto-zoom. */
}

/* Dark mode: flip backgrounds, keep accent color. */
@media (prefers-color-scheme: dark) {
  input, textarea, select {
    background: #18181b;
    border-color: #3f3f46;
    color: #fafafa;
  }
}

The four 2026-specific things in that reset worth calling out: accent-color retroactively tints native checkboxes and radios in one line; field-sizing: content on textareas auto-grows them as the user types (no JS, no resize handle); :user-invalid applies error styles only after the user has interacted with the field, fixing the "every empty field is red on page load" bug; and :focus-visible gives you a focus ring for keyboard users without showing the ring to mouse users on click.

The mobile font-size: 16pxrule is a non-obvious one: iOS Safari auto-zooms when a user taps an input with smaller text, and the only way to prevent it is to keep the input's font size at 16px or larger. Smaller mobile inputs look fine on a Mac and trash the experience on an iPhone.

For dark mode, the lazy approach is the prefers-color-scheme media query above — it kicks in automatically based on the OS setting. If your site has a manual dark-mode toggle, swap the media query for a [data-theme="dark"] selector on :root.

A few additional tweaks worth knowing about. border-radius larger than 8px starts looking dated in 2026 — most production design systems have settled on 6–10px corners on inputs. letter-spacing: -0.01em on labels and headings is the cheapest typography upgrade you can make; the default browser spacing is too loose for sans-serif at small sizes. If you want input fields to share a single rounded border (like the Apple Mail compose form), wrap them in a parent div, give the parent the border, and remove borders from each input — much smoother visually than independently bordered inputs stacked on top of each other.

Form accessibility checklist

The fastest way to ship an inaccessible form is to copy a Tailwind component out of a Pinterest screenshot. The fastest way to ship an accessible one is to walk through this six-item checklist before you commit. Every item is 2 minutes of work or less.

  • Every input has a programmatic <label>. Either wrap the input inside the label or use <label for="input-id"> + matching id. Placeholder text is not a label — it disappears the moment the user starts typing.
  • Required fields are marked visually AND with aria-required="true". A red asterisk alone isn't accessible to screen-reader users; the ARIA attribute is what announces "required" in the assistive-tech audio stream.
  • Error messages are programmatically associated. Bind error spans to inputs with aria-describedby="error-id"; mark the error span with role="alert" so screen readers announce it as soon as it appears.
  • The submit button has a visible focus state. Never outline: none without a CSS replacement. :focus-visible with a 2px outline and 2px offset is the safest default.
  • The entire form is keyboard-only navigable. Tab through every field with the mouse hidden; verify focus moves in DOM order; verify Enter submits the form from any input. If a custom dropdown or date picker breaks the keyboard flow, replace it with a native control.
  • Submit success and failure are announced. Add an aria-live="polite" region near the submit button; write to it on success ("Message sent — we'll reply within 24 hours") and on failure ("Submission failed — please try again").

The accessible-form template (#4 above) implements all six. Most of the others omit the success-region for brevity — add the <p aria-live="polite"> pattern from the newsletter template wherever production-grade accessibility matters.

Common HTML contact form mistakes

  • Removing outline without a focus replacement. Designers see the default focus ring as ugly and write * { outline: none } in the global stylesheet. Without a replacement focus indicator, the form is unusable for keyboard-only users — and is a guaranteed WCAG 2.1 AA failure. Always pair outline: none with :focus-visible { outline: 2px solid ... }.
  • Using action="mailto:you@example.com". Once a common shortcut, in 2026 this is broken on most setups: it tries to launch the user's default desktop email client, which on shared computers and most mobile devices isn't configured. Conversion rates on mailto forms run 3–10× worse than on real form backends. Use a backend endpoint instead.
  • Missing enctype="multipart/form-data" on file-upload forms. Without it, the browser only sends the filename string, not the file bytes. The form looks like it works in dev but the backend never receives the actual file. Always set enctype on any form with a type="file" input.
  • Posting PII to a Google Sheets webhook over HTTP. Sheets webhooks accept HTTPS only in 2026, but copy-pasted tutorials sometimes show http:// URLs. Verify the action URL starts with https://; PII over HTTP is a GDPR breach the moment it's logged by an intermediary.
  • Not handling spam. A form with no honeypot, no time floor, and no CAPTCHA gets 50–500 spam submissions per week within a month of going live. Add at minimum a honeypot (template #8 above). For deeper coverage see the form spam protection guide.
  • Forgetting autocomplete. Browsers fill name/email/phone fields automatically when autocomplete="name", autocomplete="email", autocomplete="tel" are set. Skipping these costs 10–15% of conversions on mobile.
  • Validating only on the client. required and pattern are enforced by the browser, not by your server. Anyone can disable JS or POST directly with curl. splitforms re-validates on the backend; if you build your own, do the same.
  • Putting the access key in JavaScript instead of the form. Some tutorials show fetch() with the access key as a JS constant. That works, but it means the form needs JS to submit at all — which breaks for users who disable JS, breaks during bot tests, and breaks when your bundler tree-shakes wrong. Keep the access key in a hidden <input> inside the <form> and the form degrades gracefully without JS.
  • Hard-coding email addresses in HTML. Putting contact@example.com in your page's visible text is fine; making it the action via mailto: is not. Bots scrape mailto: links in five minutes; a visible-but-obfuscated email address (or a contact form) reduces direct address harvesting by 80–95% according to most published spam-trap data.

FAQ

How do I make an HTML contact form actually work?

A bare HTML <form> tag has no behavior on its own — when a user clicks submit, the browser POSTs the data to whatever URL is in the action attribute, and that URL has to do something with the data. The simplest way in 2026 is to point action at a hosted form backend like https://splitforms.com/api/submit and include a hidden input named access_key with your free splitforms key. The backend receives the POST, validates the key, stores the submission, emails you, and redirects the visitor to a thank-you page. No server, no PHP, no JavaScript required.

What's the simplest HTML contact form code?

Three inputs (name, email, message) wrapped in a <form action="https://splitforms.com/api/submit" method="POST"> with a hidden access_key field and a submit button. That's about 12 lines of HTML and it works in every browser shipped after 2010. The minimal template in this post is exactly that — copy it, drop in your access key, and you have a working contact form.

How do I send HTML form submissions to my email?

Use a form backend service. The form's action attribute points at the backend URL, you add a hidden access_key input, and the backend forwards every submission to whatever email you signed up with. splitforms, Formspree, Web3Forms and Basin all do this; splitforms is the only one that's free for unlimited submissions and ships honeypot spam protection by default.

Is there a free HTML contact form generator?

Yes. splitforms (https://splitforms.com/free-contact-form) generates a copy-pasteable HTML contact form with your access key already embedded, no signup required for the first 100 submissions per month. Web3Forms also offers a free tier, but caps daily submissions on the free plan and shows branding on the success page.

Can I add a contact form to a static site?

Yes — the templates in this post all work on a static site (HTML files served from Netlify, Vercel, Cloudflare Pages, GitHub Pages, S3, or any static host). The form's action attribute points at an external backend that handles the submission. You don't need a server runtime on your hosting provider.

How do I prevent spam on an HTML contact form?

Three layers: (1) add a honeypot field — a hidden input bots fill but humans don't see; (2) set a minimum time-to-submit on the server so instant submissions are rejected; (3) add Cloudflare Turnstile or hCaptcha for the small share of sophisticated bots that get past the first two. splitforms applies all three by default. Read /blog/form-spam-protection-complete-guide-2026 for a deep dive.

Does HTML contact form code work on WordPress / Webflow / Wix?

Yes. WordPress lets you paste raw HTML into a Custom HTML block. Webflow lets you embed HTML inside an Embed element. Wix has an HTML iFrame element. In all three cases, the form's action attribute does the heavy lifting, so you don't need WordPress plugins, Webflow Forms, or Wix's built-in form builder if you'd rather use plain HTML and own your data.

Can I use this code commercially?

Yes. Every code block in this post is MIT-licensed and free to use in commercial projects without attribution. The free splitforms tier covers commercial use too — read the splitforms terms of service for the specifics around volume and acceptable-use restrictions.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open