Multistep Form
Original · freemulti-step wizard form with progress
byWeb InnoventixReact + Tailwind
formmultistepforms
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-multistep.jsonform-multistep.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type FormData = {
fullName: string;
email: string;
company: string;
projectType: string;
budget: string;
timeline: string;
services: string[];
details: string;
};
type FieldKey = keyof FormData;
type Errors = Partial<Record<FieldKey, string>>;
type StepDef = {
id: string;
title: string;
caption: string;
fields: FieldKey[];
};
const STEPS: StepDef[] = [
{
id: "about",
title: "Your details",
caption: "So we know who we're building for.",
fields: ["fullName", "email"],
},
{
id: "scope",
title: "Project scope",
caption: "The shape and size of the work.",
fields: ["projectType", "budget", "timeline"],
},
{
id: "needs",
title: "What you need",
caption: "Pick the pieces and tell us the goal.",
fields: ["services", "details"],
},
{
id: "review",
title: "Review & send",
caption: "Check the brief, then fire it over.",
fields: [],
},
];
const PROJECT_TYPES = [
"New website",
"Redesign",
"Web app",
"E-commerce",
"SEO & growth",
"Not sure yet",
];
const BUDGETS = [
{ value: "under-5k", label: "Under $5k", hint: "Landing page or small build" },
{ value: "5-15k", label: "$5k – $15k", hint: "Multi-page marketing site" },
{ value: "15-40k", label: "$15k – $40k", hint: "Custom app or store" },
{ value: "40k-plus", label: "$40k+", hint: "Platform or long engagement" },
];
const TIMELINES = [
"ASAP (1–2 weeks)",
"This quarter",
"Next quarter",
"Flexible / exploring",
];
const SERVICES = [
"UX & UI design",
"Frontend development",
"Backend & APIs",
"SEO & content",
"Brand & identity",
"Ongoing maintenance",
];
const INITIAL: FormData = {
fullName: "",
email: "",
company: "",
projectType: "",
budget: "",
timeline: "",
services: [],
details: "",
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateStep(index: number, data: FormData): Errors {
const errors: Errors = {};
if (index === 0) {
if (!data.fullName.trim()) errors.fullName = "Please tell us your name.";
if (!data.email.trim()) errors.email = "An email is required.";
else if (!EMAIL_RE.test(data.email.trim()))
errors.email = "That email doesn't look right.";
}
if (index === 1) {
if (!data.projectType) errors.projectType = "Pick the closest match.";
if (!data.budget) errors.budget = "Choose a rough budget band.";
if (!data.timeline) errors.timeline = "When should this launch?";
}
if (index === 2) {
if (data.services.length === 0)
errors.services = "Select at least one service.";
if (data.details.trim().length < 15)
errors.details = "Give us a sentence or two (15+ characters).";
}
return errors;
}
export default function FormMultistep() {
const uid = useId();
const reduce = useReducedMotion() ?? false;
const [step, setStep] = useState(0);
const [maxReached, setMaxReached] = useState(0);
const [direction, setDirection] = useState(1);
const [data, setData] = useState<FormData>(INITIAL);
const [errors, setErrors] = useState<Errors>({});
const [shake, setShake] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const headingRef = useRef<HTMLHeadingElement>(null);
const liveRef = useRef<HTMLDivElement>(null);
const totalSteps = STEPS.length;
const pct = useMemo(
() => Math.round(((step + 1) / totalSteps) * 100),
[step, totalSteps],
);
useEffect(() => {
headingRef.current?.focus();
}, [step, submitted]);
function set<K extends FieldKey>(key: K, value: FormData[K]) {
setData((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => {
if (!prev[key]) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}
function toggleService(name: string) {
setData((prev) => {
const has = prev.services.includes(name);
return {
...prev,
services: has
? prev.services.filter((s) => s !== name)
: [...prev.services, name],
};
});
setErrors((prev) => {
if (!prev.services) return prev;
const next = { ...prev };
delete next.services;
return next;
});
}
function announce(message: string) {
if (liveRef.current) liveRef.current.textContent = message;
}
function goNext() {
const found = validateStep(step, data);
if (Object.keys(found).length > 0) {
setErrors(found);
setShake(true);
announce(
`${Object.keys(found).length} field${
Object.keys(found).length > 1 ? "s need" : " needs"
} attention before continuing.`,
);
return;
}
setErrors({});
const next = Math.min(step + 1, totalSteps - 1);
setDirection(1);
setStep(next);
setMaxReached((m) => Math.max(m, next));
}
function goBack() {
if (step === 0) return;
setDirection(-1);
setErrors({});
setStep((s) => s - 1);
}
function jumpTo(target: number) {
if (target > maxReached || target === step) return;
setDirection(target > step ? 1 : -1);
setErrors({});
setStep(target);
}
function handleSubmit() {
setSubmitting(true);
announce("Sending your brief.");
window.setTimeout(() => {
setSubmitting(false);
setSubmitted(true);
announce("Brief sent. We'll be in touch shortly.");
}, 1100);
}
function reset() {
setData(INITIAL);
setErrors({});
setStep(0);
setMaxReached(0);
setDirection(-1);
setSubmitted(false);
}
const firstName = data.fullName.trim().split(/\s+/)[0] || "there";
const slideVariants = {
enter: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * 36,
}),
center: { opacity: 1, x: 0 },
exit: (dir: number) => ({
opacity: 0,
x: reduce ? 0 : dir * -36,
}),
};
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes fmw-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-7px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
@keyframes fmw-pop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fmw-draw {
from { stroke-dashoffset: 48; }
to { stroke-dashoffset: 0; }
}
@keyframes fmw-rise {
0% { transform: translateY(10px) scale(0.9); opacity: 0; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes fmw-spin {
to { transform: rotate(360deg); }
}
.fmw-shake { animation: fmw-shake 0.42s cubic-bezier(.36,.07,.19,.97) both; }
.fmw-pop { animation: fmw-pop 0.5s cubic-bezier(.34,1.56,.64,1) both; }
.fmw-draw { stroke-dasharray: 48; animation: fmw-draw 0.6s 0.15s ease forwards; }
.fmw-rise { animation: fmw-rise 0.45s ease both; }
.fmw-spin { animation: fmw-spin 0.7s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.fmw-shake, .fmw-pop, .fmw-draw, .fmw-rise, .fmw-spin {
animation: none !important;
}
.fmw-draw { stroke-dashoffset: 0; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<div className="mb-8 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<SparkIcon className="h-3.5 w-3.5" />
Project intake
</span>
<h2 className="mt-4 text-2xl font-bold tracking-tight sm:text-3xl">
Start your project brief
</h2>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
Four short steps. Roughly two minutes. No account needed.
</p>
</div>
<div
ref={liveRef}
aria-live="polite"
className="sr-only"
role="status"
/>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20">
{/* Progress + stepper */}
<div className="border-b border-slate-200 px-6 pt-6 pb-5 dark:border-slate-800">
<div className="mb-4 flex items-center justify-between text-xs font-medium">
<span className="text-slate-500 dark:text-slate-400">
Step {Math.min(step + 1, totalSteps)} of {totalSteps}
</span>
<span className="text-indigo-600 dark:text-indigo-400">
{submitted ? "Complete" : `${pct}%`}
</span>
</div>
<div
className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={submitted ? 100 : pct}
aria-valuetext={
submitted
? "Complete"
: `Step ${step + 1} of ${totalSteps}: ${STEPS[step].title}`
}
>
<motion.div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
initial={false}
animate={{ width: `${submitted ? 100 : pct}%` }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 220, damping: 30 }
}
/>
</div>
<ol className="mt-5 flex items-center justify-between gap-1">
{STEPS.map((s, i) => {
const done = i < step || submitted;
const current = i === step && !submitted;
const reachable = i <= maxReached && !submitted;
return (
<li key={s.id} className="flex min-w-0 flex-1 flex-col items-center">
<button
type="button"
onClick={() => jumpTo(i)}
disabled={!reachable || current}
aria-current={current ? "step" : undefined}
aria-label={`Step ${i + 1}: ${s.title}${
done ? " (completed)" : ""
}`}
className={[
"flex h-9 w-9 items-center justify-center rounded-full border text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900",
reachable && !current
? "cursor-pointer"
: "cursor-default",
done
? "border-indigo-500 bg-indigo-500 text-white"
: current
? "border-indigo-500 bg-white text-indigo-600 dark:bg-slate-900 dark:text-indigo-300"
: "border-slate-300 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-500",
].join(" ")}
>
{done ? (
<CheckIcon className="h-4 w-4" />
) : (
<span>{i + 1}</span>
)}
</button>
<span
className={[
"mt-2 hidden truncate text-center text-[11px] font-medium sm:block",
current
? "text-slate-900 dark:text-slate-100"
: "text-slate-500 dark:text-slate-500",
].join(" ")}
>
{s.title}
</span>
</li>
);
})}
</ol>
</div>
{/* Body */}
<form
onSubmit={(e) => {
e.preventDefault();
if (submitted) return;
if (step < totalSteps - 1) goNext();
else handleSubmit();
}}
noValidate
>
<div
className={[
"px-6 py-7 sm:px-8",
shake ? "fmw-shake" : "",
].join(" ")}
onAnimationEnd={() => setShake(false)}
>
{submitted ? (
<SuccessPanel
headingRef={headingRef}
firstName={firstName}
onReset={reset}
/>
) : (
<AnimatePresence mode="wait" custom={direction} initial={false}>
<motion.div
key={STEPS[step].id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={reduce ? { duration: 0 } : { duration: 0.28, ease: "easeOut" }}
>
<h3
ref={headingRef}
tabIndex={-1}
className="text-lg font-bold tracking-tight focus-visible:outline-none"
>
{STEPS[step].title}
</h3>
<p className="mt-1 mb-6 text-sm text-slate-600 dark:text-slate-400">
{STEPS[step].caption}
</p>
{step === 0 && (
<div className="space-y-5">
<Field
id={`${uid}-name`}
label="Full name"
required
error={errors.fullName}
>
<input
id={`${uid}-name`}
type="text"
autoComplete="name"
value={data.fullName}
onChange={(e) => set("fullName", e.target.value)}
placeholder="Salman Ahmed"
aria-invalid={!!errors.fullName}
aria-describedby={
errors.fullName ? `${uid}-name-err` : undefined
}
className={inputClass(!!errors.fullName)}
/>
</Field>
<Field
id={`${uid}-email`}
label="Work email"
required
error={errors.email}
>
<input
id={`${uid}-email`}
type="email"
inputMode="email"
autoComplete="email"
value={data.email}
onChange={(e) => set("email", e.target.value)}
placeholder="you@company.com"
aria-invalid={!!errors.email}
aria-describedby={
errors.email ? `${uid}-email-err` : undefined
}
className={inputClass(!!errors.email)}
/>
</Field>
<Field
id={`${uid}-company`}
label="Company"
optional
>
<input
id={`${uid}-company`}
type="text"
autoComplete="organization"
value={data.company}
onChange={(e) => set("company", e.target.value)}
placeholder="Optional"
className={inputClass(false)}
/>
</Field>
</div>
)}
{step === 1 && (
<div className="space-y-6">
<Field
id={`${uid}-ptype`}
label="Project type"
required
error={errors.projectType}
>
<div className="relative">
<select
id={`${uid}-ptype`}
value={data.projectType}
onChange={(e) => set("projectType", e.target.value)}
aria-invalid={!!errors.projectType}
aria-describedby={
errors.projectType
? `${uid}-ptype-err`
: undefined
}
className={[
inputClass(!!errors.projectType),
"appearance-none pr-10",
data.projectType
? ""
: "text-slate-400 dark:text-slate-500",
].join(" ")}
>
<option value="" disabled>
Choose the closest match…
</option>
{PROJECT_TYPES.map((t) => (
<option
key={t}
value={t}
className="text-slate-900 dark:text-slate-100"
>
{t}
</option>
))}
</select>
<ChevronIcon className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
</div>
</Field>
<fieldset>
<legend className="flex items-center gap-1.5 text-sm font-medium">
Budget band
<span className="text-rose-500" aria-hidden="true">
*
</span>
</legend>
<div
role="radiogroup"
aria-label="Budget band"
aria-invalid={!!errors.budget}
className="mt-3 grid grid-cols-1 gap-2.5 sm:grid-cols-2"
>
{BUDGETS.map((b) => {
const checked = data.budget === b.value;
return (
<label
key={b.value}
className={[
"group relative flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition-colors",
"focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 dark:focus-within:ring-offset-slate-900",
checked
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600",
].join(" ")}
>
<input
type="radio"
name={`${uid}-budget`}
value={b.value}
checked={checked}
onChange={() => set("budget", b.value)}
className="sr-only"
/>
<span
aria-hidden="true"
className={[
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
checked
? "border-indigo-500"
: "border-slate-300 dark:border-slate-600",
].join(" ")}
>
{checked && (
<span className="h-2 w-2 rounded-full bg-indigo-500" />
)}
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold">
{b.label}
</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
{b.hint}
</span>
</span>
</label>
);
})}
</div>
{errors.budget && (
<ErrorText id={`${uid}-budget-err`}>
{errors.budget}
</ErrorText>
)}
</fieldset>
<Field
id={`${uid}-timeline`}
label="Timeline"
required
error={errors.timeline}
>
<div className="relative">
<select
id={`${uid}-timeline`}
value={data.timeline}
onChange={(e) => set("timeline", e.target.value)}
aria-invalid={!!errors.timeline}
aria-describedby={
errors.timeline
? `${uid}-timeline-err`
: undefined
}
className={[
inputClass(!!errors.timeline),
"appearance-none pr-10",
data.timeline
? ""
: "text-slate-400 dark:text-slate-500",
].join(" ")}
>
<option value="" disabled>
When should this launch?
</option>
{TIMELINES.map((t) => (
<option
key={t}
value={t}
className="text-slate-900 dark:text-slate-100"
>
{t}
</option>
))}
</select>
<ChevronIcon className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
</div>
</Field>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<fieldset>
<legend className="flex items-center gap-1.5 text-sm font-medium">
Services you need
<span className="text-rose-500" aria-hidden="true">
*
</span>
</legend>
<div className="mt-3 grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{SERVICES.map((s) => {
const checked = data.services.includes(s);
return (
<label
key={s}
className={[
"flex cursor-pointer items-center gap-3 rounded-xl border p-3 text-sm font-medium transition-colors",
"focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 dark:focus-within:ring-offset-slate-900",
checked
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600",
].join(" ")}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleService(s)}
className="sr-only"
/>
<span
aria-hidden="true"
className={[
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 transition-colors",
checked
? "border-indigo-500 bg-indigo-500 text-white"
: "border-slate-300 dark:border-slate-600",
].join(" ")}
>
{checked && <CheckIcon className="h-3.5 w-3.5" />}
</span>
{s}
</label>
);
})}
</div>
{errors.services && (
<ErrorText id={`${uid}-services-err`}>
{errors.services}
</ErrorText>
)}
</fieldset>
<Field
id={`${uid}-details`}
label="Tell us the goal"
required
error={errors.details}
>
<textarea
id={`${uid}-details`}
value={data.details}
onChange={(e) => set("details", e.target.value)}
rows={4}
placeholder="What are you trying to achieve, and by when? Any links or references help."
aria-invalid={!!errors.details}
aria-describedby={
errors.details
? `${uid}-details-err`
: `${uid}-details-help`
}
className={[
inputClass(!!errors.details),
"resize-y leading-relaxed",
].join(" ")}
/>
{!errors.details && (
<p
id={`${uid}-details-help`}
className="mt-1.5 text-xs text-slate-500 dark:text-slate-400"
>
{data.details.trim().length}/15 characters minimum
</p>
)}
</Field>
</div>
)}
{step === 3 && (
<ReviewPanel data={data} onEdit={jumpTo} />
)}
</motion.div>
</AnimatePresence>
)}
</div>
{/* Footer controls */}
{!submitted && (
<div className="flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 sm:px-8 dark:border-slate-800">
<button
type="button"
onClick={goBack}
disabled={step === 0}
className={[
"inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900",
step === 0
? "cursor-not-allowed text-slate-300 dark:text-slate-700"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
].join(" ")}
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
{step < totalSteps - 1 ? (
<button
type="submit"
className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900"
>
Continue
<ArrowRightIcon className="h-4 w-4" />
</button>
) : (
<button
type="submit"
disabled={submitting}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-emerald-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 disabled:opacity-70 dark:focus-visible:ring-offset-slate-900"
>
{submitting ? (
<>
<SpinnerIcon className="fmw-spin h-4 w-4" />
Sending…
</>
) : (
<>
Send brief
<SparkIcon className="h-4 w-4" />
</>
)}
</button>
)}
</div>
)}
</form>
</div>
<p className="mt-4 text-center text-xs text-slate-500 dark:text-slate-500">
We reply within one business day. Your details stay private.
</p>
</div>
</section>
);
}
function inputClass(hasError: boolean): string {
return [
"w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900",
hasError
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/60"
: "border-slate-300 focus-visible:ring-indigo-500 dark:border-slate-700",
].join(" ");
}
function Field({
id,
label,
required,
optional,
error,
children,
}: {
id: string;
label: string;
required?: boolean;
optional?: boolean;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<label
htmlFor={id}
className="mb-2 flex items-center gap-1.5 text-sm font-medium"
>
{label}
{required && (
<span className="text-rose-500" aria-hidden="true">
*
</span>
)}
{optional && (
<span className="text-xs font-normal text-slate-400">optional</span>
)}
</label>
{children}
{error && <ErrorText id={`${id}-err`}>{error}</ErrorText>}
</div>
);
}
function ErrorText({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) {
return (
<p
id={id}
role="alert"
className="mt-1.5 flex items-center gap-1 text-xs font-medium text-rose-600 dark:text-rose-400"
>
<AlertIcon className="h-3.5 w-3.5 shrink-0" />
{children}
</p>
);
}
function ReviewPanel({
data,
onEdit,
}: {
data: FormData;
onEdit: (target: number) => void;
}) {
const rows: { label: string; value: string; step: number }[] = [
{ label: "Name", value: data.fullName || "—", step: 0 },
{ label: "Email", value: data.email || "—", step: 0 },
{ label: "Company", value: data.company || "Not provided", step: 0 },
{ label: "Project type", value: data.projectType || "—", step: 1 },
{
label: "Budget",
value:
BUDGETS.find((b) => b.value === data.budget)?.label || "—",
step: 1,
},
{ label: "Timeline", value: data.timeline || "—", step: 1 },
{
label: "Services",
value: data.services.length ? data.services.join(", ") : "—",
step: 2,
},
{ label: "Goal", value: data.details || "—", step: 2 },
];
return (
<dl className="divide-y divide-slate-200 rounded-xl border border-slate-200 dark:divide-slate-800 dark:border-slate-800">
{rows.map((r, i) => (
<div
key={r.label}
className="fmw-rise flex items-start gap-3 px-4 py-3"
style={{ animationDelay: `${i * 0.04}s` }}
>
<dt className="w-24 shrink-0 pt-0.5 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{r.label}
</dt>
<dd className="min-w-0 flex-1 break-words text-sm text-slate-800 dark:text-slate-200">
{r.value}
</dd>
<button
type="button"
onClick={() => onEdit(r.step)}
className="shrink-0 rounded px-2 py-0.5 text-xs font-semibold text-indigo-600 transition-colors hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
>
Edit
</button>
</div>
))}
</dl>
);
}
function SuccessPanel({
headingRef,
firstName,
onReset,
}: {
headingRef: React.RefObject<HTMLHeadingElement | null>;
firstName: string;
onReset: () => void;
}) {
return (
<div className="flex flex-col items-center py-6 text-center">
<div className="fmw-pop flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-500/15">
<svg
viewBox="0 0 24 24"
className="h-8 w-8 text-emerald-600 dark:text-emerald-400"
fill="none"
aria-hidden="true"
>
<path
className="fmw-draw"
d="M5 13l4 4L19 7"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h3
ref={headingRef}
tabIndex={-1}
className="mt-5 text-xl font-bold tracking-tight focus-visible:outline-none"
>
Brief sent, {firstName}.
</h3>
<p className="mt-2 max-w-sm text-sm text-slate-600 dark:text-slate-400">
We've got your project details. Expect a reply from a real person within
one business day, with a couple of follow-up questions and next steps.
</p>
<button
type="button"
onClick={onReset}
className="mt-6 inline-flex items-center gap-1.5 rounded-lg border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Submit another brief
</button>
</div>
);
}
/* ---------- Inline SVG icons ---------- */
function CheckIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
className={className}
fill="none"
aria-hidden="true"
>
<path
d="M5 13l4 4L19 7"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ArrowLeftIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<path
d="M19 12H5m0 0l6 6m-6-6l6-6"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ArrowRightIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<path
d="M5 12h14m0 0l-6-6m6 6l-6 6"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function AlertIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={2} />
<path
d="M12 8v4m0 4h.01"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
/>
</svg>
);
}
function SparkIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<path
d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z"
stroke="currentColor"
strokeWidth={2}
strokeLinejoin="round"
/>
</svg>
);
}
function SpinnerIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth={2.5}
opacity={0.25}
/>
<path
d="M21 12a9 9 0 00-9-9"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
/>
</svg>
);
}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

Contact Form
Originalcontact form with fields and message

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

