Web InnoventixFreeCode

Multistep Form

Original · free

multi-step wizard form with progress

byWeb InnoventixReact + Tailwind
formmultistepforms
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-multistep.json
form-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 quote

Similar components

Browse all →