Web InnoventixFreeCode

Contact Form

Original · free

contact form with fields and message

byWeb InnoventixReact + Tailwind
formcontactforms
Open

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.json
form-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&apos;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 &ldquo;your call is important to us.&rdquo;
            </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&apos;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&apos;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 quote

Similar components

Browse all →