Comparison

Tailwind contact form template

Tailwind CSS forms that look finished and also send. Three plain HTML templates with a small script, and the contact form again as a React component. They use only Tailwind's default classes, so nothing needs to be added to your config.

Short answer: Copy contact.html into a page that loads Tailwind, replace fk_your_access_key with your Formgong key, and the form will send messages to your email.

Contact form (HTML)

Name and email side by side on wider screens, the message below, and a status line next to the button. The script keeps the visitor on the page and prints the outcome there. Without JavaScript the form still posts and Formgong shows its own thank-you page.

contact.html
<form id="contact-form" action="https://formgong.com/submit" method="POST" class="relative mx-auto grid max-w-xl gap-5 rounded-2xl bg-white p-8 shadow-lg ring-1 ring-slate-900/5">
  <input type="hidden" name="access_key" value="fk_your_access_key">
  <div>
    <h2 class="text-2xl font-bold tracking-tight text-slate-900">Get in touch</h2>
    <p class="mt-1 text-slate-600">We answer within one working day.</p>
  </div>
  <div class="grid gap-5 sm:grid-cols-2">
    <label class="block text-sm font-medium text-slate-700">Name
      <input type="text" name="name" autocomplete="name" required class="mt-1 block w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30">
    </label>
    <label class="block text-sm font-medium text-slate-700">Email
      <input type="email" name="email" autocomplete="email" required class="mt-1 block w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30">
    </label>
  </div>
  <label class="block text-sm font-medium text-slate-700">Message
    <textarea name="message" rows="5" required class="mt-1 block w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30"></textarea>
  </label>
  <!-- Honeypot: people never see it, simple bots fill it in. -->
  <div aria-hidden="true" class="absolute h-px w-px overflow-hidden [clip-path:inset(50%)]">
    <input type="text" name="botcheck" tabindex="-1" autocomplete="off">
  </div>
  <div class="flex flex-wrap items-center gap-4">
    <button type="submit" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-5 py-2.5 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-wait disabled:opacity-60">Send message</button>
    <p data-status role="status" class="text-sm text-slate-700"></p>
  </div>
</form>
<script>
  document.getElementById("contact-form").addEventListener("submit", async (event) => {
    event.preventDefault();
    const form = event.currentTarget;
    const button = form.querySelector("button[type=submit]");
    const status = form.querySelector("[data-status]");
    const label = button.textContent;
    button.disabled = true;
    button.textContent = "Sending…";
    status.textContent = "";
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.success === true) {
        form.reset();
        status.textContent = "Thanks! Your message has been sent.";
      } else {
        status.textContent = result.message || "Not sent. Please try again.";
      }
    } catch {
      status.textContent = "Not sent: check your connection and try again.";
    }
    button.disabled = false;
    button.textContent = label;
  });
</script>

Newsletter signup (dark)

A single-row signup on a dark panel. On phones the button drops below the field.

newsletter.html
<form id="newsletter-form" action="https://formgong.com/submit" method="POST" class="relative mx-auto max-w-xl rounded-2xl bg-slate-900 p-8 text-white">
  <input type="hidden" name="access_key" value="fk_your_access_key">
  <input type="hidden" name="_subject" value="New newsletter signup">
  <h2 class="text-xl font-semibold">Get new posts by email</h2>
  <p class="mt-1 text-sm text-slate-300">One email a month. Unsubscribe any time.</p>
  <div class="mt-5 flex flex-col gap-3 sm:flex-row">
    <label for="newsletter-email" class="sr-only">Email</label>
    <input id="newsletter-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" required
      class="min-w-0 flex-auto rounded-lg border-0 bg-white/10 px-3.5 py-2.5 text-white ring-1 ring-inset ring-white/20 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-indigo-400">
    <button type="submit" class="rounded-lg bg-indigo-500 px-4 py-2.5 font-semibold text-white hover:bg-indigo-400 disabled:cursor-wait disabled:opacity-60">Subscribe</button>
  </div>
  <!-- Honeypot: people never see it, simple bots fill it in. -->
  <div aria-hidden="true" class="absolute h-px w-px overflow-hidden [clip-path:inset(50%)]">
    <input type="text" name="botcheck" tabindex="-1" autocomplete="off">
  </div>
  <p data-status role="status" class="mt-3 text-sm text-slate-200"></p>
</form>
<script>
  document.getElementById("newsletter-form").addEventListener("submit", async (event) => {
    event.preventDefault();
    const form = event.currentTarget;
    const button = form.querySelector("button[type=submit]");
    const status = form.querySelector("[data-status]");
    const label = button.textContent;
    button.disabled = true;
    button.textContent = "Subscribing…";
    status.textContent = "";
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.success === true) {
        form.reset();
        status.textContent = "You're subscribed. Thanks!";
      } else {
        status.textContent = result.message || "Not sent. Please try again.";
      }
    } catch {
      status.textContent = "Not sent: check your connection and try again.";
    }
    button.disabled = false;
    button.textContent = label;
  });
</script>

Feedback form with 1–5 buttons

The score buttons are real radio inputs hidden with sr-only, styled through the peer-checked variant, so keyboard and screen reader users get a normal radio group.

feedback.html
<form id="feedback-form" action="https://formgong.com/submit" method="POST" class="relative mx-auto grid max-w-xl gap-5 rounded-2xl bg-white p-8 shadow-lg ring-1 ring-slate-900/5">
  <input type="hidden" name="access_key" value="fk_your_access_key">
  <input type="hidden" name="_subject" value="New feedback">
  <fieldset>
    <legend class="text-sm font-medium text-slate-700">How was your experience?</legend>
    <div class="mt-2 flex gap-2">
      <label class="cursor-pointer"><input type="radio" name="rating" value="1" required class="peer sr-only"><span class="grid h-11 w-11 place-items-center rounded-lg border border-slate-300 font-semibold text-slate-700 peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500">1</span></label>
      <label class="cursor-pointer"><input type="radio" name="rating" value="2" class="peer sr-only"><span class="grid h-11 w-11 place-items-center rounded-lg border border-slate-300 font-semibold text-slate-700 peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500">2</span></label>
      <label class="cursor-pointer"><input type="radio" name="rating" value="3" class="peer sr-only"><span class="grid h-11 w-11 place-items-center rounded-lg border border-slate-300 font-semibold text-slate-700 peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500">3</span></label>
      <label class="cursor-pointer"><input type="radio" name="rating" value="4" class="peer sr-only"><span class="grid h-11 w-11 place-items-center rounded-lg border border-slate-300 font-semibold text-slate-700 peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500">4</span></label>
      <label class="cursor-pointer"><input type="radio" name="rating" value="5" class="peer sr-only"><span class="grid h-11 w-11 place-items-center rounded-lg border border-slate-300 font-semibold text-slate-700 peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500">5</span></label>
    </div>
  </fieldset>
  <label class="block text-sm font-medium text-slate-700">What should we change?
    <textarea name="message" rows="4" class="mt-1 block w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30"></textarea>
  </label>
  <label class="block text-sm font-medium text-slate-700">Email <span class="font-normal text-slate-500">(only if you want a reply)</span>
    <input type="email" name="email" autocomplete="email" class="mt-1 block w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30">
  </label>
  <!-- Honeypot: people never see it, simple bots fill it in. -->
  <div aria-hidden="true" class="absolute h-px w-px overflow-hidden [clip-path:inset(50%)]">
    <input type="text" name="botcheck" tabindex="-1" autocomplete="off">
  </div>
  <div class="flex flex-wrap items-center gap-4">
    <button type="submit" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-5 py-2.5 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-wait disabled:opacity-60">Send feedback</button>
    <p data-status role="status" class="text-sm text-slate-700"></p>
  </div>
</form>
<script>
  document.getElementById("feedback-form").addEventListener("submit", async (event) => {
    event.preventDefault();
    const form = event.currentTarget;
    const button = form.querySelector("button[type=submit]");
    const status = form.querySelector("[data-status]");
    const label = button.textContent;
    button.disabled = true;
    button.textContent = "Sending…";
    status.textContent = "";
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.success === true) {
        form.reset();
        status.textContent = "Thanks for the feedback!";
      } else {
        status.textContent = result.message || "Not sent. Please try again.";
      }
    } catch {
      status.textContent = "Not sent: check your connection and try again.";
    }
    button.disabled = false;
    button.textContent = label;
  });
</script>

Contact form as a React component

The same design for React, Next.js or any app that already runs Tailwind. The error text turns red.

ContactForm.jsx
import { useState } from "react";

const ACCESS_KEY = "fk_your_access_key";
const input = "mt-1 block w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30";

export default function ContactForm() {
  const [status, setStatus] = useState("idle");
  const [notice, setNotice] = useState("");

  async function handleSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    data.set("access_key", ACCESS_KEY);
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await response.json();
      if (result.success === true) {
        form.reset();
        setStatus("sent");
        setNotice("Thanks! Your message has been sent.");
      } else {
        setStatus("error");
        setNotice(result.message || "Not sent. Please try again.");
      }
    } catch {
      setStatus("error");
      setNotice("Not sent: check your connection and try again.");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="relative mx-auto grid max-w-xl gap-5 rounded-2xl bg-white p-8 shadow-lg ring-1 ring-slate-900/5">
      <h2 className="text-2xl font-bold tracking-tight text-slate-900">Get in touch</h2>
      <div className="grid gap-5 sm:grid-cols-2">
        <label className="block text-sm font-medium text-slate-700">
          Name
          <input type="text" name="name" autoComplete="name" required className={input} />
        </label>
        <label className="block text-sm font-medium text-slate-700">
          Email
          <input type="email" name="email" autoComplete="email" required className={input} />
        </label>
      </div>
      <label className="block text-sm font-medium text-slate-700">
        Message
        <textarea name="message" rows={5} required className={input} />
      </label>
      <div aria-hidden="true" className="absolute h-px w-px overflow-hidden [clip-path:inset(50%)]">
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <div className="flex flex-wrap items-center gap-4">
        <button type="submit" disabled={status === "sending"} className="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-5 py-2.5 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-wait disabled:opacity-60">
          {status === "sending" ? "Sending…" : "Send message"}
        </button>
        <p role={status === "error" ? "alert" : "status"} className={status === "error" ? "text-sm text-red-700" : "text-sm text-slate-700"}>
          {notice}
        </p>
      </div>
    </form>
  );
}

Tailwind 3 or 4?

We ran these with Tailwind 4.3 only. They use standard utility classes, plus one arbitrary property, [clip-path:inset(50%)], on the hidden honeypot. If you are on Tailwind 3, build once and check that the honeypot stays invisible before you go live.

How we tested this code

We pasted each snippet unchanged into a fresh project (Tailwind CSS 4.3, both in plain HTML built with the Tailwind CLI and in React 19.3), filled in a real access key, and submitted it in Chromium three times against a local copy of Formgong. With the right key the form showed its success text and exactly one submission was stored, not marked as spam. With a key Formgong does not know, the form showed Formgong's own error, "That access key does not exist.", and nothing was stored. With the request cut off, the form said it was not sent. None of the snippets says "sent" before Formgong answers success: true.

Questions

Is it safe to put the access key in front-end code?

Yes. The key only lets someone send a submission to your form, the same thing your page already does. It cannot read submissions or change settings. If you want it out of the page source anyway, add your domain under allowed domains in the form settings, or post through your own server.

Can I use these with the Tailwind Play CDN?

Yes. The CDN script generates the classes in the browser, so the templates work as they are. For a production site, build the CSS with the Tailwind CLI or your bundler.

How do I change the colours?

Replace indigo with another palette name (sky, emerald, rose…) in the button and focus classes. The rest is slate.

What is the hidden botcheck field?

A honeypot. It sits off screen, so people never fill it, while simple bots fill every field they find. Formgong marks a submission with it filled in as spam, so it does not reach your email. Keep the name botcheck.

Sources

Checked on 16.10.2026 against these public pages: Tailwind CSS: Styling based on sibling state (peer), Tailwind CSS: Installing with the Tailwind CLI.

← Formgong