Contact Form
Original · freecontact form with fields and message
byWeb InnoventixReact + Tailwind
formcontactforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-contact.jsonform-contact.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, FormEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Topic = "general" | "project" | "support" | "partnership";
interface FormState {
name: string;
email: string;
company: string;
topic: Topic | "";
message: string;
consent: boolean;
}
type Errors = Partial<Record<keyof FormState, string>>;
const TOPICS: { value: Topic; label: string; hint: string }[] = [
{ value: "project", label: "New project", hint: "Scope, timeline & a quote" },
{ value: "partnership", label: "Partnership", hint: "Co-marketing or referrals" },
{ value: "support", label: "Support", hint: "Help with existing work" },
{ value: "general", label: "General", hint: "Anything else on your mind" },
];
const MAX_MESSAGE = 1000;
const MIN_MESSAGE = 20;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const INITIAL: FormState = {
name: "",
email: "",
company: "",
topic: "",
message: "",
consent: false,
};
function validate(values: FormState): Errors {
const errors: Errors = {};
if (!values.name.trim()) errors.name = "Please tell us your name.";
if (!values.email.trim()) errors.email = "We need an email to reply.";
else if (!EMAIL_RE.test(values.email.trim())) errors.email = "That email doesn't look right.";
if (!values.topic) errors.topic = "Pick what this is about.";
if (!values.message.trim()) errors.message = "Add a short message so we can help.";
else if (values.message.trim().length < MIN_MESSAGE)
errors.message = `A little more detail helps — ${MIN_MESSAGE} characters minimum.`;
if (!values.consent) errors.consent = "Please agree before sending.";
return errors;
}
export default function FormContact() {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const fid = (name: string) => `${uid}-${name}`;
const [values, setValues] = useState<FormState>(INITIAL);
const [errors, setErrors] = useState<Errors>({});
const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle");
const [shaking, setShaking] = useState(false);
const [announce, setAnnounce] = useState("");
const fieldRefs = useRef<Partial<Record<keyof FormState, HTMLElement | null>>>({});
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const setField = (name: keyof FormState, value: string | boolean) => {
setValues((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
};
const onText =
(name: keyof FormState) =>
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setField(name, e.target.value);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (status === "submitting") return;
const found = validate(values);
if (Object.keys(found).length > 0) {
setErrors(found);
setShaking(true);
setAnnounce(`Please fix ${Object.keys(found).length} field(s) before sending.`);
const order: (keyof FormState)[] = ["name", "email", "topic", "message", "consent"];
const first = order.find((k) => found[k]);
if (first) fieldRefs.current[first]?.focus();
return;
}
setStatus("submitting");
setAnnounce("Sending your message…");
timer.current = setTimeout(() => {
setStatus("success");
setAnnounce("Message sent. We'll be in touch within one business day.");
}, 1500);
};
const reset = () => {
setValues(INITIAL);
setErrors({});
setStatus("idle");
setAnnounce("");
};
const messageLen = values.message.length;
const overLimit = messageLen > MAX_MESSAGE;
const inputBase =
"w-full rounded-xl border bg-white px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/15 dark:bg-zinc-950 dark:text-slate-100 dark:placeholder:text-slate-500";
const ok = "border-slate-300 dark:border-zinc-700";
const bad = "border-rose-400 focus:border-rose-500 focus:ring-rose-500/15 dark:border-rose-500/70";
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-zinc-950">
<style>{`
@keyframes fc-shake {
0%,100% { transform: translateX(0); }
15%,55% { transform: translateX(-7px); }
35%,75% { transform: translateX(7px); }
}
@keyframes fc-float {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(14px,-18px) scale(1.06); }
}
@keyframes fc-check {
to { stroke-dashoffset: 0; }
}
@keyframes fc-ring {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.fc-shake { animation: fc-shake 0.5s cubic-bezier(.36,.07,.19,.97); }
.fc-blob { animation: fc-float 11s ease-in-out infinite; }
.fc-blob-2 { animation: fc-float 14s ease-in-out infinite reverse; }
.fc-ring { animation: fc-ring 0.5s cubic-bezier(.16,1,.3,1) both; }
.fc-check { stroke-dasharray: 30; stroke-dashoffset: 30; animation: fc-check 0.55s 0.15s ease-out forwards; }
@media (prefers-reduced-motion: reduce) {
.fc-shake, .fc-blob, .fc-blob-2, .fc-ring, .fc-check { animation: none !important; }
.fc-check { stroke-dashoffset: 0; }
}
`}</style>
<p role="status" aria-live="polite" className="sr-only">
{announce}
</p>
<motion.div
initial={reduce ? false : { opacity: 0, y: 26 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: reduce ? 0 : 0.5, ease: "easeOut" }}
className="relative mx-auto grid max-w-6xl overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 lg:grid-cols-[0.9fr_1.1fr] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40"
>
{/* Info panel — deliberately single-look */}
<div className="relative overflow-hidden bg-gradient-to-br from-indigo-600 via-indigo-700 to-violet-800 p-8 text-indigo-50 sm:p-10">
<div
aria-hidden="true"
className="fc-blob pointer-events-none absolute -right-16 -top-20 h-56 w-56 rounded-full bg-violet-400/30 blur-3xl"
/>
<div
aria-hidden="true"
className="fc-blob-2 pointer-events-none absolute -bottom-24 -left-10 h-56 w-56 rounded-full bg-sky-400/25 blur-3xl"
/>
<div className="relative">
<span className="inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium tracking-wide text-indigo-100 ring-1 ring-inset ring-white/20">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
Usually replies in a day
</span>
<h2 className="mt-6 text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Let's build something worth shipping.
</h2>
<p className="mt-4 max-w-sm text-sm leading-relaxed text-indigo-100/90">
Tell us about the work. A real person at Northbeam Studio reads every message —
no bots, no ticket queue, no “your call is important to us.”
</p>
<dl className="mt-10 space-y-5 text-sm">
<ContactRow label="Email" value="hello@northbeam.studio" href="mailto:hello@northbeam.studio">
<path d="M4 6h16v12H4z" />
<path d="m4 7 8 6 8-6" />
</ContactRow>
<ContactRow label="Phone" value="+1 (415) 555-0142" href="tel:+14155550142">
<path d="M5 4h4l2 5-3 2a12 12 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z" />
</ContactRow>
<ContactRow label="Studio" value="220 Harrison St, San Francisco">
<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10Z" />
<circle cx="12" cy="11" r="2.2" />
</ContactRow>
</dl>
<div className="mt-10 flex items-center gap-3 rounded-2xl bg-white/10 p-4 ring-1 ring-inset ring-white/15">
<div className="flex -space-x-2">
{["EM", "JR", "SP"].map((i) => (
<span
key={i}
className="grid h-8 w-8 place-items-center rounded-full bg-indigo-300/90 text-[11px] font-semibold text-indigo-900 ring-2 ring-indigo-700"
>
{i}
</span>
))}
</div>
<p className="text-xs leading-snug text-indigo-100/90">
The team that answers is the team that does the work.
</p>
</div>
</div>
</div>
{/* Form panel */}
<div className="p-8 sm:p-10">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="success"
role="status"
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -12 }}
transition={{ duration: reduce ? 0 : 0.35, ease: "easeOut" }}
className="flex min-h-[26rem] flex-col items-center justify-center text-center"
>
<div className="fc-ring grid h-16 w-16 place-items-center rounded-full bg-emerald-100 dark:bg-emerald-500/15">
<svg viewBox="0 0 24 24" fill="none" className="h-8 w-8 text-emerald-600 dark:text-emerald-400" aria-hidden="true">
<path
className="fc-check"
d="m5 13 4 4 10-10"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h3 className="mt-6 text-xl font-semibold text-slate-900 dark:text-white">
Message sent — thank you.
</h3>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-slate-600 dark:text-slate-400">
We've got it, {values.name.trim().split(" ")[0] || "friend"}. Expect a reply
at <span className="font-medium text-slate-800 dark:text-slate-200">{values.email}</span>{" "}
within one business day.
</p>
<button
type="button"
onClick={reset}
className="mt-8 inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-slate-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
Send another message
</button>
</motion.div>
) : (
<motion.form
key="form"
noValidate
onSubmit={handleSubmit}
initial={false}
exit={reduce ? undefined : { opacity: 0 }}
className={`space-y-6 ${shaking ? "fc-shake" : ""}`}
onAnimationEnd={() => setShaking(false)}
>
<div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
Send us a message
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Fields marked with <span className="text-rose-500">*</span> are required.
</p>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label htmlFor={fid("name")} className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Full name <span className="text-rose-500">*</span>
</label>
<input
id={fid("name")}
ref={(el) => {
fieldRefs.current.name = el;
}}
type="text"
autoComplete="name"
value={values.name}
onChange={onText("name")}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? fid("name-err") : undefined}
placeholder="Ada Lovelace"
className={`${inputBase} ${errors.name ? bad : ok}`}
/>
<FieldError id={fid("name-err")} message={errors.name} reduce={reduce} />
</div>
<div className="space-y-1.5">
<label htmlFor={fid("email")} className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Email <span className="text-rose-500">*</span>
</label>
<input
id={fid("email")}
ref={(el) => {
fieldRefs.current.email = el;
}}
type="email"
inputMode="email"
autoComplete="email"
value={values.email}
onChange={onText("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? fid("email-err") : undefined}
placeholder="ada@company.com"
className={`${inputBase} ${errors.email ? bad : ok}`}
/>
<FieldError id={fid("email-err")} message={errors.email} reduce={reduce} />
</div>
</div>
<div className="space-y-1.5">
<label htmlFor={fid("company")} className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Company <span className="font-normal text-slate-400">(optional)</span>
</label>
<input
id={fid("company")}
type="text"
autoComplete="organization"
value={values.company}
onChange={onText("company")}
placeholder="Where do you work?"
className={`${inputBase} ${ok}`}
/>
</div>
{/* Topic radio group */}
<fieldset
aria-describedby={errors.topic ? fid("topic-err") : undefined}
aria-invalid={!!errors.topic}
>
<legend className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
What's this about? <span className="text-rose-500">*</span>
</legend>
<div className="grid gap-2.5 sm:grid-cols-2">
{TOPICS.map((t, i) => (
<label
key={t.value}
className="group relative flex cursor-pointer items-start gap-3 rounded-xl border border-slate-300 bg-white p-3.5 transition hover:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-950 dark:hover:border-indigo-500"
>
<input
type="radio"
name={fid("topic")}
value={t.value}
ref={i === 0 ? (el) => { fieldRefs.current.topic = el; } : undefined}
checked={values.topic === t.value}
onChange={() => setField("topic", t.value)}
className="peer sr-only"
/>
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border border-slate-400 transition peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:[&>span]:opacity-100 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 dark:peer-focus-visible:ring-offset-zinc-900">
<span className="h-1.5 w-1.5 rounded-full bg-white opacity-0 transition" />
</span>
<span className="min-w-0 peer-checked:[&>span:first-child]:text-indigo-700 dark:peer-checked:[&>span:first-child]:text-indigo-300">
<span className="block text-sm font-medium text-slate-800 dark:text-slate-200">
{t.label}
</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">{t.hint}</span>
</span>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-xl ring-2 ring-inset ring-indigo-500 opacity-0 transition peer-checked:opacity-100"
/>
</label>
))}
</div>
<FieldError id={fid("topic-err")} message={errors.topic} reduce={reduce} />
</fieldset>
{/* Message */}
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label htmlFor={fid("message")} className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Message <span className="text-rose-500">*</span>
</label>
<span
className={`text-xs tabular-nums ${
overLimit ? "text-rose-500" : "text-slate-400 dark:text-slate-500"
}`}
>
{messageLen}/{MAX_MESSAGE}
</span>
</div>
<textarea
id={fid("message")}
ref={(el) => {
fieldRefs.current.message = el;
}}
rows={5}
value={values.message}
onChange={onText("message")}
aria-invalid={!!errors.message || overLimit}
aria-describedby={errors.message ? fid("message-err") : undefined}
placeholder="What are you trying to make? Share goals, timeline, links — whatever gives us context."
className={`${inputBase} resize-y ${errors.message || overLimit ? bad : ok}`}
/>
<FieldError id={fid("message-err")} message={errors.message} reduce={reduce} />
</div>
{/* Consent */}
<div>
<label className="group flex cursor-pointer items-start gap-3">
<input
type="checkbox"
ref={(el) => {
fieldRefs.current.consent = el;
}}
checked={values.consent}
onChange={(e) => setField("consent", e.target.checked)}
aria-invalid={!!errors.consent}
aria-describedby={errors.consent ? fid("consent-err") : undefined}
className="peer sr-only"
/>
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border border-slate-400 bg-white transition peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:[&>svg]:opacity-100 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 dark:bg-zinc-950 dark:peer-focus-visible:ring-offset-zinc-900">
<svg viewBox="0 0 20 20" fill="none" className="h-3.5 w-3.5 text-white opacity-0 transition" aria-hidden="true">
<path d="m4 10 3.5 3.5L16 5" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
I agree to Northbeam Studio using these details to reply to my enquiry. We never
sell or share your data.
</span>
</label>
<FieldError id={fid("consent-err")} message={errors.consent} reduce={reduce} />
</div>
<div className="flex flex-col-reverse items-center gap-4 pt-1 sm:flex-row sm:justify-between">
<p className="text-xs text-slate-400 dark:text-slate-500">
Prefer email? Reach us at{" "}
<a
href="mailto:hello@northbeam.studio"
className="font-medium text-indigo-600 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
hello@northbeam.studio
</a>
</p>
<button
type="submit"
disabled={status === "submitting" || overLimit}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white shadow-sm shadow-indigo-600/20 transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto dark:focus-visible:ring-offset-zinc-900"
>
{status === "submitting" ? (
<>
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4 animate-spin" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
Sending…
</>
) : (
<>
Send message
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M5 12h13m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</>
)}
</button>
</div>
</motion.form>
)}
</AnimatePresence>
</div>
</motion.div>
</section>
);
}
function ContactRow({
label,
value,
href,
children,
}: {
label: string;
value: string;
href?: string;
children: ReactNode;
}) {
const inner = (
<>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-white/10 ring-1 ring-inset ring-white/15">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 text-indigo-100" aria-hidden="true">
{children}
</svg>
</span>
<span className="min-w-0">
<dt className="text-xs uppercase tracking-wide text-indigo-200/70">{label}</dt>
<dd className="truncate text-sm font-medium text-white">{value}</dd>
</span>
</>
);
if (href) {
return (
<a
href={href}
className="flex items-center gap-3 rounded-lg outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-700"
>
{inner}
</a>
);
}
return <div className="flex items-center gap-3">{inner}</div>;
}
function FieldError({
id,
message,
reduce,
}: {
id: string;
message?: string;
reduce: boolean;
}) {
return (
<AnimatePresence initial={false}>
{message ? (
<motion.p
key="err"
id={id}
role="alert"
initial={reduce ? { opacity: 1 } : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: reduce ? 0 : 0.2, ease: "easeOut" }}
className="flex items-center gap-1.5 overflow-hidden pt-0.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5 shrink-0" aria-hidden="true">
<path
fillRule="evenodd"
d="M10 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm.75 4.5a.75.75 0 0 0-1.5 0v4.5a.75.75 0 0 0 1.5 0V6.5ZM10 13a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"
clipRule="evenodd"
/>
</svg>
{message}
</motion.p>
) : null}
</AnimatePresence>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

Booking Form
Originalbooking form with date and slots

