Web InnoventixFreeCode

Form Modal

Original · free

modal containing a form

byWeb InnoventixReact + Tailwind
modalformmodals
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/modal-form.json
modal-form.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type FormEvent,
} from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type Variants,
} from "motion/react";

type ProjectType = "website" | "webapp" | "redesign" | "growth";
type Status = "idle" | "submitting" | "success";

interface FormValues {
  name: string;
  email: string;
  projectType: ProjectType;
  budget: string;
  details: string;
}

interface FormErrors {
  name?: string;
  email?: string;
  budget?: string;
}

const PROJECT_TYPES: { id: ProjectType; label: string; hint: string; path: string }[] = [
  { id: "website", label: "Marketing site", hint: "Positioning + launch", path: "M12 3a9 9 0 100 18 9 9 0 000-18zm0 0c2.5 2.4 4 5.7 4 9s-1.5 6.6-4 9m0-18c-2.5 2.4-4 5.7-4 9s1.5 6.6 4 9M3.6 9h16.8M3.6 15h16.8" },
  { id: "webapp", label: "Web application", hint: "Product build", path: "M4 5h16v11H4zM4 20h16M9.5 8.5L7 11l2.5 2.5M14.5 8.5L17 11l-2.5 2.5" },
  { id: "redesign", label: "Redesign", hint: "Rebrand + refresh", path: "M12 3l2.1 5.9L20 11l-5.9 2.1L12 19l-2.1-5.9L4 11l5.9-2.1L12 3z" },
  { id: "growth", label: "SEO & growth", hint: "Rank + convert", path: "M4 19h16M6 15l4-5 3 3 5-7M18 6h2v2" },
];

const BUDGETS = ["Under $5k", "$5k – $15k", "$15k – $40k", "$40k+"];

const FOCUSABLE =
  'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';

const EMPTY: FormValues = {
  name: "",
  email: "",
  projectType: "website",
  budget: "",
  details: "",
};

export default function ModalForm() {
  const reduce = useReducedMotion();

  const [open, setOpen] = useState(false);
  const [values, setValues] = useState<FormValues>(EMPTY);
  const [errors, setErrors] = useState<FormErrors>({});
  const [status, setStatus] = useState<Status>("idle");

  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const firstFieldRef = useRef<HTMLInputElement>(null);
  const doneRef = useRef<HTMLButtonElement>(null);
  const timerRef = useRef<number | null>(null);

  function openModal() {
    setValues(EMPTY);
    setErrors({});
    setStatus("idle");
    setOpen(true);
  }

  function closeModal() {
    setOpen(false);
  }

  function update<K extends keyof FormValues>(key: K, value: FormValues[K]) {
    setValues((prev) => ({ ...prev, [key]: value }));
    if (key in errors) {
      setErrors((prev) => {
        const next = { ...prev };
        delete next[key as keyof FormErrors];
        return next;
      });
    }
  }

  function validate(v: FormValues): FormErrors {
    const next: FormErrors = {};
    if (!v.name.trim()) next.name = "Please tell us your name.";
    if (!v.email.trim()) next.email = "An email is required.";
    else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email))
      next.email = "That email doesn't look right.";
    if (!v.budget) next.budget = "Pick a range so we can scope it.";
    return next;
  }

  function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const found = validate(values);
    setErrors(found);
    if (Object.keys(found).length > 0) {
      const firstKey = Object.keys(found)[0];
      const el = panelRef.current?.querySelector<HTMLElement>(
        `[data-field="${firstKey}"]`,
      );
      el?.focus();
      return;
    }
    setStatus("submitting");
    if (timerRef.current) window.clearTimeout(timerRef.current);
    timerRef.current = window.setTimeout(() => setStatus("success"), 900);
  }

  // Focus trap, scroll lock, Escape, and focus restoration while the dialog is open.
  useEffect(() => {
    if (!open) return;

    const previouslyFocused = document.activeElement as HTMLElement | null;
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    const raf = window.requestAnimationFrame(() => firstFieldRef.current?.focus());

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        event.preventDefault();
        closeModal();
        return;
      }
      if (event.key !== "Tab" || !panelRef.current) return;
      const nodes = Array.from(
        panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE),
      ).filter((n) => n.offsetParent !== null || n === document.activeElement);
      if (nodes.length === 0) return;
      const first = nodes[0];
      const last = nodes[nodes.length - 1];
      const active = document.activeElement;
      if (event.shiftKey && active === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && active === last) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener("keydown", onKeyDown);
    return () => {
      window.cancelAnimationFrame(raf);
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = prevOverflow;
      previouslyFocused?.focus?.();
    };
  }, [open]);

  useEffect(() => {
    if (status === "success") doneRef.current?.focus();
  }, [status]);

  useEffect(
    () => () => {
      if (timerRef.current) window.clearTimeout(timerRef.current);
    },
    [],
  );

  const backdrop: Variants = {
    hidden: { opacity: 0 },
    show: { opacity: 1 },
    exit: { opacity: 0 },
  };

  const panel: Variants = {
    hidden: { opacity: 0, y: reduce ? 0 : 18, scale: reduce ? 1 : 0.97 },
    show: {
      opacity: 1,
      y: 0,
      scale: 1,
      transition: reduce
        ? { duration: 0.15 }
        : { type: "spring", stiffness: 280, damping: 26 },
    },
    exit: {
      opacity: 0,
      y: reduce ? 0 : 10,
      scale: reduce ? 1 : 0.98,
      transition: { duration: 0.16, ease: "easeIn" },
    },
  };

  const inputClass =
    "block w-full rounded-xl border bg-white px-3.5 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:bg-zinc-900 dark:text-white dark:placeholder:text-zinc-500";

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-6 py-24 dark:bg-zinc-950 md:py-32">
      <style>{`
        @keyframes mdlf-orb { 0%,100% { transform: translate3d(0,0,0) scale(1); } 50% { transform: translate3d(0,-14px,0) scale(1.06); } }
        @keyframes mdlf-sheen { to { background-position: 200% center; } }
        @keyframes mdlf-check { to { stroke-dashoffset: 0; } }
        .mdlf-orb { animation: mdlf-orb 12s ease-in-out infinite; }
        .mdlf-sheen { background-size: 200% auto; animation: mdlf-sheen 6s linear infinite; }
        .mdlf-check { stroke-dasharray: 32; stroke-dashoffset: 32; animation: mdlf-check 0.55s ease-out 0.1s forwards; }
        @media (prefers-reduced-motion: reduce) {
          .mdlf-orb, .mdlf-sheen, .mdlf-check { animation: none; }
          .mdlf-check { stroke-dashoffset: 0; }
        }
      `}</style>

      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
        <div className="mdlf-orb absolute -left-24 top-8 h-72 w-72 rounded-full bg-gradient-to-br from-indigo-300 to-violet-300 opacity-40 blur-3xl dark:from-indigo-600 dark:to-violet-700 dark:opacity-20" />
        <div className="mdlf-orb absolute -right-16 bottom-0 h-64 w-64 rounded-full bg-gradient-to-br from-sky-300 to-emerald-200 opacity-40 blur-3xl dark:from-sky-700 dark:to-emerald-800 dark:opacity-20 [animation-delay:-4s]" />
      </div>

      <div className="mx-auto max-w-2xl text-center">
        <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/70 px-3.5 py-1 text-xs font-medium text-zinc-600 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300">
          <span className="relative flex h-2 w-2">
            <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" />
            <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
          </span>
          Booking projects for Q4
        </span>

        <h2 className="mt-6 text-balance text-4xl font-semibold tracking-tight text-zinc-900 dark:text-white sm:text-5xl">
          Scope your next build in{" "}
          <span className="mdlf-sheen bg-gradient-to-r from-indigo-600 via-violet-500 to-indigo-600 bg-clip-text text-transparent dark:from-indigo-400 dark:via-violet-400 dark:to-indigo-400">
            five questions
          </span>
        </h2>

        <p className="mx-auto mt-4 max-w-lg text-pretty text-base text-zinc-600 dark:text-zinc-400 sm:text-lg">
          No discovery call to get a straight answer. Fill the brief and a senior
          engineer replies within one business day with honest scope and pricing.
        </p>

        <button
          ref={triggerRef}
          type="button"
          onClick={openModal}
          aria-haspopup="dialog"
          aria-expanded={open}
          className="group mt-9 inline-flex items-center gap-2 rounded-xl bg-zinc-900 px-6 py-3.5 text-sm font-semibold text-white shadow-lg shadow-zinc-900/10 transition hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:bg-white dark:text-zinc-900 dark:shadow-black/30 dark:hover:bg-zinc-100 dark:focus-visible:ring-offset-zinc-950"
        >
          Open the project brief
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden
            className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
          >
            <path d="M5 12h14M13 6l6 6-6 6" />
          </svg>
        </button>
      </div>

      <AnimatePresence>
        {open && (
          <motion.div
            key="mdlf-modal"
            initial={{ opacity: 1 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 1 }}
            className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
          >
            <motion.div
              variants={backdrop}
              initial="hidden"
              animate="show"
              exit="exit"
              transition={{ duration: 0.2 }}
              onClick={closeModal}
              className="absolute inset-0 bg-zinc-950/50 backdrop-blur-sm"
            />

            <motion.div
              ref={panelRef}
              variants={panel}
              initial="hidden"
              animate="show"
              exit="exit"
              role="dialog"
              aria-modal="true"
              aria-labelledby="mdlf-title"
              aria-describedby="mdlf-desc"
              className="relative flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-2xl shadow-zinc-900/20 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/50"
            >
              <div className="flex items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5 dark:border-zinc-800">
                <div>
                  <h3
                    id="mdlf-title"
                    className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-white"
                  >
                    Tell us about your project
                  </h3>
                  <p
                    id="mdlf-desc"
                    className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"
                  >
                    Takes about a minute. No sales script attached.
                  </p>
                </div>
                <button
                  type="button"
                  onClick={closeModal}
                  aria-label="Close dialog"
                  className="-mr-1.5 -mt-1.5 flex h-9 w-9 flex-none items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
                >
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden
                    className="h-5 w-5"
                  >
                    <path d="M6 6l12 12M18 6L6 18" />
                  </svg>
                </button>
              </div>

              <div className="overflow-y-auto overscroll-contain px-6 py-6">
                {status === "success" ? (
                  <div className="flex flex-col items-center py-6 text-center">
                    <span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-500/15">
                      <svg
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={2.5}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden
                        className="h-7 w-7 text-emerald-600 dark:text-emerald-400"
                      >
                        <path className="mdlf-check" d="M5 13l4 4L19 7" />
                      </svg>
                    </span>
                    <h4 className="mt-5 text-lg font-semibold text-zinc-900 dark:text-white">
                      Brief received{values.name.trim() ? `, ${values.name.trim().split(" ")[0]}` : ""}.
                    </h4>
                    <p className="mt-2 max-w-xs text-sm text-zinc-500 dark:text-zinc-400">
                      We&apos;ll email{" "}
                      <span className="font-medium text-zinc-700 dark:text-zinc-200">
                        {values.email}
                      </span>{" "}
                      within one business day with scope and a fixed quote.
                    </p>
                    <button
                      ref={doneRef}
                      type="button"
                      onClick={closeModal}
                      className="mt-7 inline-flex items-center justify-center rounded-xl bg-zinc-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100 dark:focus-visible:ring-offset-zinc-900"
                    >
                      Done
                    </button>
                  </div>
                ) : (
                  <form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
                    <div>
                      <label
                        htmlFor="mdlf-name"
                        className="block text-sm font-medium text-zinc-800 dark:text-zinc-200"
                      >
                        Full name
                      </label>
                      <input
                        ref={firstFieldRef}
                        id="mdlf-name"
                        name="name"
                        data-field="name"
                        type="text"
                        autoComplete="name"
                        value={values.name}
                        onChange={(e: ChangeEvent<HTMLInputElement>) =>
                          update("name", e.target.value)
                        }
                        placeholder="Alex Morgan"
                        aria-invalid={Boolean(errors.name)}
                        aria-describedby={errors.name ? "mdlf-name-err" : undefined}
                        className={`mt-2 ${inputClass} ${
                          errors.name
                            ? "border-rose-400 focus-visible:ring-rose-500/40 dark:border-rose-500"
                            : "border-zinc-300 dark:border-zinc-700"
                        }`}
                      />
                      {errors.name && (
                        <p
                          id="mdlf-name-err"
                          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                        >
                          {errors.name}
                        </p>
                      )}
                    </div>

                    <div>
                      <label
                        htmlFor="mdlf-email"
                        className="block text-sm font-medium text-zinc-800 dark:text-zinc-200"
                      >
                        Work email
                      </label>
                      <input
                        id="mdlf-email"
                        name="email"
                        data-field="email"
                        type="email"
                        autoComplete="email"
                        value={values.email}
                        onChange={(e: ChangeEvent<HTMLInputElement>) =>
                          update("email", e.target.value)
                        }
                        placeholder="you@company.com"
                        aria-invalid={Boolean(errors.email)}
                        aria-describedby={errors.email ? "mdlf-email-err" : undefined}
                        className={`mt-2 ${inputClass} ${
                          errors.email
                            ? "border-rose-400 focus-visible:ring-rose-500/40 dark:border-rose-500"
                            : "border-zinc-300 dark:border-zinc-700"
                        }`}
                      />
                      {errors.email && (
                        <p
                          id="mdlf-email-err"
                          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                        >
                          {errors.email}
                        </p>
                      )}
                    </div>

                    <fieldset>
                      <legend className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
                        What are we building?
                      </legend>
                      <div className="mt-2 grid grid-cols-2 gap-2.5">
                        {PROJECT_TYPES.map((t) => (
                          <div key={t.id} className="relative">
                            <input
                              id={`mdlf-type-${t.id}`}
                              type="radio"
                              name="projectType"
                              value={t.id}
                              checked={values.projectType === t.id}
                              onChange={() => update("projectType", t.id)}
                              className="peer sr-only"
                            />
                            <label
                              htmlFor={`mdlf-type-${t.id}`}
                              className="flex cursor-pointer flex-col gap-1.5 rounded-xl border border-zinc-300 bg-white p-3 transition peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/50 hover:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/10 dark:hover:border-zinc-600"
                            >
                              <svg
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth={1.6}
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                aria-hidden
                                className="h-5 w-5 text-zinc-500 peer-checked:text-indigo-600 dark:text-zinc-400"
                              >
                                <path d={t.path} />
                              </svg>
                              <span className="text-sm font-medium text-zinc-900 dark:text-white">
                                {t.label}
                              </span>
                              <span className="text-xs text-zinc-500 dark:text-zinc-400">
                                {t.hint}
                              </span>
                            </label>
                          </div>
                        ))}
                      </div>
                    </fieldset>

                    <div>
                      <label
                        htmlFor="mdlf-budget"
                        className="block text-sm font-medium text-zinc-800 dark:text-zinc-200"
                      >
                        Budget range
                      </label>
                      <select
                        id="mdlf-budget"
                        name="budget"
                        data-field="budget"
                        value={values.budget}
                        onChange={(e: ChangeEvent<HTMLSelectElement>) =>
                          update("budget", e.target.value)
                        }
                        aria-invalid={Boolean(errors.budget)}
                        aria-describedby={errors.budget ? "mdlf-budget-err" : undefined}
                        className={`mt-2 ${inputClass} ${
                          values.budget ? "" : "text-zinc-400 dark:text-zinc-500"
                        } ${
                          errors.budget
                            ? "border-rose-400 focus-visible:ring-rose-500/40 dark:border-rose-500"
                            : "border-zinc-300 dark:border-zinc-700"
                        }`}
                      >
                        <option value="" disabled>
                          Select a range…
                        </option>
                        {BUDGETS.map((b) => (
                          <option key={b} value={b} className="text-zinc-900">
                            {b}
                          </option>
                        ))}
                      </select>
                      {errors.budget && (
                        <p
                          id="mdlf-budget-err"
                          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                        >
                          {errors.budget}
                        </p>
                      )}
                    </div>

                    <div>
                      <label
                        htmlFor="mdlf-details"
                        className="block text-sm font-medium text-zinc-800 dark:text-zinc-200"
                      >
                        Anything else?{" "}
                        <span className="font-normal text-zinc-400 dark:text-zinc-500">
                          optional
                        </span>
                      </label>
                      <textarea
                        id="mdlf-details"
                        name="details"
                        rows={3}
                        value={values.details}
                        onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
                          update("details", e.target.value)
                        }
                        placeholder="Deadlines, links, the problem you're solving…"
                        className={`mt-2 resize-none ${inputClass} border-zinc-300 dark:border-zinc-700`}
                      />
                    </div>

                    <div className="mt-1 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end">
                      <button
                        type="button"
                        onClick={closeModal}
                        className="inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-medium text-zinc-600 transition hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-300 dark:hover:bg-zinc-800"
                      >
                        Cancel
                      </button>
                      <button
                        type="submit"
                        disabled={status === "submitting"}
                        className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-70 dark:focus-visible:ring-offset-zinc-900"
                      >
                        {status === "submitting" ? (
                          <>
                            <svg
                              viewBox="0 0 24 24"
                              fill="none"
                              aria-hidden
                              className="h-4 w-4 animate-spin"
                            >
                              <circle
                                cx="12"
                                cy="12"
                                r="9"
                                stroke="currentColor"
                                strokeWidth="3"
                                className="opacity-25"
                              />
                              <path
                                d="M21 12a9 9 0 00-9-9"
                                stroke="currentColor"
                                strokeWidth="3"
                                strokeLinecap="round"
                              />
                            </svg>
                            Sending…
                          </>
                        ) : (
                          "Send brief"
                        )}
                      </button>
                    </div>

                    <p className="text-center text-xs text-zinc-400 dark:text-zinc-500 sm:text-right">
                      No spam. We reply within one business day.
                    </p>
                  </form>
                )}
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </section>
  );
}

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 →