
Free contact form in HTML and CSS with floating labels, :user-invalid error styling, messages tied by aria-describedby, and a React and Tailwind version.
components/we-inc/contact-form-with-floating-labels-html-css.tsx
import { useState, type FocusEvent, type FormEvent } from "react";
type Field = HTMLInputElement | HTMLTextAreaElement;
// Field list: change labels, types and error text here.
const FIELDS = [
{ name: "name", label: "Your name", type: "text", auto: "name", error: "Please tell us your name." },
{ name: "email", label: "Email address", type: "email", auto: "email", error: "Enter an email like ana@example.com." },
] as const;
const MESSAGE_ERROR = "A sentence or two helps us quote, at least 10 characters.";
export default function ContactFormWithFloatingLabels() {
// A field only shows its error after it has been left once, like CSS :user-invalid.
const [bad, setBad] = useState<Record<string, boolean>>({});
const [sent, setSent] = useState(false);
const check = (e: FocusEvent<Field>) => setBad((b) => ({ ...b, [e.target.name]: !e.target.validity.valid }));
const recheck = (e: FormEvent<Field>) => {
const el = e.currentTarget;
if (bad[el.name] && el.validity.valid) setBad((b) => ({ ...b, [el.name]: false }));
};
const submit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const next: Record<string, boolean> = {};
for (const el of Array.from(form.elements) as Field[]) if (el.name) next[el.name] = !el.validity.valid;
setBad(next);
const first = form.querySelector<HTMLElement>(":invalid");
if (first) first.focus();
else setSent(true);
};
// The peer classes float the label when the field has focus or text.
const box = (n: string) =>
`peer block w-full rounded-xl border bg-white px-4 pb-2 pt-6 text-[15px] text-slate-900 placeholder-transparent focus:outline-none focus:ring-4 ${bad[n] ? "border-rose-500 focus:ring-rose-100" : "border-slate-300 focus:border-teal-700 focus:ring-teal-100"}`;
const label = "pointer-events-none absolute left-4 top-2 text-xs font-medium text-slate-600 transition-all duration-150 peer-placeholder-shown:top-4 peer-placeholder-shown:text-[15px] peer-focus:top-2 peer-focus:text-xs peer-focus:text-teal-800 motion-reduce:transition-none";
const err = (n: string, text: string) => bad[n] ? <p id={`cffl-${n}-err`} className="mt-1.5 text-sm text-rose-700">{text}</p> : null;
return (
<section className="bg-[#eef3f1] px-4 py-16 sm:py-20">
<div className="mx-auto grid max-w-4xl gap-10 md:grid-cols-[1fr_1.3fr]">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-teal-800">Saltmarsh Joinery</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900">Tell us about your project</h2>
<p className="mt-3 text-slate-700">Kitchens, built-in shelving and staircases across the county. We reply within two working days.</p>
<dl className="mt-6 space-y-2 text-sm text-slate-700">
<div><dt className="inline font-medium text-slate-900">Workshop: </dt><dd className="inline">Unit 4, Quay Lane</dd></div>
<div><dt className="inline font-medium text-slate-900">Phone: </dt><dd className="inline">01632 960 418</dd></div>
</dl>
</div>
<form noValidate onSubmit={submit} className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
{sent ? (
<div role="status" className="py-10 text-center">
<p className="text-lg font-semibold text-slate-900">Thanks, your message is in.</p>
<p className="mt-2 text-slate-700">Mara will get back to you by Thursday.</p>
</div>
) : (
<div className="space-y-5">
{FIELDS.map((f) => (
<div key={f.name}>
<div className="relative">
<input
id={`cffl-${f.name}`}
name={f.name}
type={f.type}
autoComplete={f.auto}
required
placeholder={f.label}
onBlur={check}
onInput={recheck}
aria-invalid={bad[f.name] || undefined}
aria-describedby={bad[f.name] ? `cffl-${f.name}-err` : undefined}
className={box(f.name)}
/>
<label htmlFor={`cffl-${f.name}`} className={label}>{f.label}</label>
</div>
{err(f.name, f.error)}
</div>
))}
<div>
<div className="relative">
<textarea
id="cffl-message"
name="message"
rows={4}
required
minLength={10}
placeholder="What would you like built?"
onBlur={check}
onInput={recheck}
aria-invalid={bad.message || undefined}
aria-describedby={bad.message ? "cffl-message-err" : undefined}
className={`${box("message")} resize-y`}
/>
<label htmlFor="cffl-message" className={label}>What would you like built?</label>
</div>
{err("message", MESSAGE_ERROR)}
</div>
<button type="submit" className="h-12 w-full rounded-xl bg-teal-800 text-[15px] font-semibold text-white hover:bg-teal-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-800 focus-visible:ring-offset-2">Send message</button>
</div>
)}
</form>
</div>
</section>
);
}
