The contact page is often the last step before a lead becomes a conversation. A cluttered or intimidating form kills conversions. A clean, well-structured form with clear labels, smart defaults, and visual feedback makes it easy for visitors to reach out.
This guide covers five React contact form patterns with Tailwind CSS — from a simple three-field form to a multi-step wizard with progress indicators.
1. The Simple Contact Form
Three fields, one button. Name, email, message. This is all most businesses need.
"use client";
import { useState } from "react";
interface FormData {
name: string;
email: string;
message: string;
}
function ContactForm() {
const [form, setForm] = useState<FormData>({
name: "",
email: "",
message: "",
});
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) {
setForm({ ...form, [e.target.name]: e.target.value });
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) {
setForm({ name: "", email: "", message: "" });
}
}
return (
<form onSubmit={handleSubmit} className="mx-auto max-w-lg space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Name
</label>
<input
id="name"
name="name"
type="text"
required
value={form.name}
onChange={handleChange}
className="w-full rounded-lg border border-neutral-300 px-4 py-2.5 text-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:focus:border-white dark:focus:ring-white"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
id="email"
name="email"
type="email"
required
value={form.email}
onChange={handleChange}
className="w-full rounded-lg border border-neutral-300 px-4 py-2.5 text-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:focus:border-white dark:focus:ring-white"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-2">
Message
</label>
<textarea
id="message"
name="message"
rows={5}
required
value={form.message}
onChange={handleChange}
className="w-full rounded-lg border border-neutral-300 px-4 py-2.5 text-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:focus:border-white dark:focus:ring-white resize-none"
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-neutral-900 py-2.5 text-sm font-semibold text-white hover:bg-neutral-700 transition-colors dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
>
Send message
</button>
</form>
);
}
Every input has a <label> with a matching htmlFor/id pair. This is non-negotiable for accessibility — clicking the label should focus the input. The required attribute provides native browser validation before the form is submitted.
Form Design Principles
Before exploring more patterns, here are the design rules that apply to every contact form:
Label above the field, not beside it. Top-aligned labels scan faster than side-aligned labels. Eye-tracking studies show users read top-aligned forms 50% faster.
One column only. Two-column form layouts cause users to miss fields. The only exception is first name / last name on the same row, which is a universally understood pattern.
Button should describe the action. "Send message" is better than "Submit". "Request a callback" is better than "Send". The label should match what happens next.
Show success feedback. After submission, replace the form with a confirmation message or show a toast notification. Never leave the user wondering whether the form went through.
2. Split Layout: Form + Info
The most popular contact page pattern for business websites. The left column has the form; the right column has contact information (address, phone, email) and office hours.
Use grid md:grid-cols-2 gap-12 for the layout. The info column helps visitors who prefer email or phone over the form, and it adds credibility by showing a physical address.
Add social links in the info column if the business has active social accounts. This gives visitors a third channel option and signals that the company is approachable.
3. Contact Form with Map
For businesses with physical locations, embedding a map next to the contact form provides context. The map can sit above the form (full-width) or beside it (split layout).
Use a static map image from Mapbox or Google Maps Static API instead of an interactive embed. The interactive Google Maps iframe loads 500KB+ of JavaScript, which hurts page performance. A static image with a link to Google Maps gives the same value at a fraction of the cost.
If you do need an interactive map, lazy-load the iframe so it only loads when visible:
<iframe
src={`https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${encodeURIComponent(address)}`}
className="h-64 w-full rounded-xl border-0"
loading="lazy"
allowFullScreen
/>
4. Floating Labels
Floating labels start inside the input field as placeholder text, then animate upward when the user focuses or types. This saves vertical space while maintaining clear labeling.
The CSS trick: use peer and peer-placeholder-shown utilities in Tailwind to control the label position based on input state:
<div className="relative">
<input
id="email"
name="email"
type="email"
placeholder=" "
required
className="peer w-full rounded-lg border border-neutral-300 px-4 pt-5 pb-2 text-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
/>
<label
htmlFor="email"
className="absolute left-4 top-2 text-xs text-neutral-500 transition-all peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-sm peer-focus:top-2 peer-focus:text-xs peer-focus:text-neutral-900"
>
Email address
</label>
</div>
The placeholder=" " (a single space) is the key. The peer-placeholder-shown state is true when the placeholder is visible (input is empty and not focused), which positions the label as a placeholder. When the user types or focuses, the label floats up.
This pattern is elegant but has a trade-off: the label movement can confuse some users, and the smaller label text at the top is harder to read for users with visual impairments. Use it for design-forward sites; stick with standard top labels for accessibility-critical forms.
5. Multi-Step Form
For forms with many fields (job applications, detailed inquiries, onboarding), a multi-step wizard reduces cognitive load by showing only a few fields at a time.
Structure the form as an array of steps, each with its own fields and validation. A progress indicator at the top shows the current step:
const steps = [
{ title: "Your info", fields: ["name", "email", "phone"] },
{ title: "Project details", fields: ["budget", "timeline", "description"] },
{ title: "Review & send", fields: [] },
];
Navigate between steps with "Next" and "Back" buttons. Validate the current step's fields before allowing the user to proceed. The final step shows a summary of all entered data with an edit button for each section.
Keep the total number of steps to three or four. More than that creates form fatigue. Each step should take under 30 seconds to complete.
Validation and Error States
Client-side validation should happen on blur (when the user leaves a field), not on change (while they're still typing). Showing errors mid-keystroke is frustrating.
Style error states with a red border and an error message below the field:
<input
className={`w-full rounded-lg border px-4 py-2.5 text-sm ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-neutral-300 focus:border-neutral-900 focus:ring-neutral-900"
}`}
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
Use aria-invalid="true" and aria-describedby pointing to the error message for screen reader support. Native required, type="email", and minLength attributes provide a free first layer of validation.
Spam Prevention
Contact forms attract bots. Three effective countermeasures:
- Honeypot field: add a hidden field. Bots fill it in; humans don't. Reject submissions where the hidden field has a value.
- Time-based check: record when the page loaded. Reject submissions that happen within 2 seconds — a human can't fill a form that fast.
- reCAPTCHA v3 or Turnstile: invisible challenges that score the submission. Use this as a last resort since it adds an external dependency.
Pre-Built Contact Sections
Building accessible, responsive forms with validation, error states, and spam prevention takes time. The Incubator contact catalog has 10+ ready-to-use contact form sections — simple, split, with map, floating labels, multi-step — all built in React with Tailwind CSS.
Explore the full component library for every section your site needs, from FAQ sections to testimonials.