Form Modal
Original · freemodal containing a form
byWeb InnoventixReact + Tailwind
modalformmodals
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.jsonmodal-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'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 quoteSimilar components
Browse all →
Basic Modal
Originalbasic centred modal with overlay

Center Modal
Originalcentred modal with icon and actions

Confirm Modal
Originalconfirm/destructive dialog

Alert Modal
Originalalert dialog with single action

Drawer Bottom Modal
Originalbottom sheet drawer

Side Drawer Modal
Originalright side drawer modal

Fullscreen Modal
Originalfullscreen modal

Image Lightbox Modal
Originalimage lightbox modal with nav

Video Modal
Originalvideo player modal

Scroll Modal
Originalmodal with long scrollable body

Steps Modal
Originalmulti-step modal flow

Command Palette Modal
Originalcommand palette modal with search

