React Form Wizard
Examples

Multi-step Checkout

A complete checkout with per-step validation, delivery options and a live order summary.

Multi-step Checkout

The most-requested build: one form, several steps, each gating on its own fields. Contact details, a delivery address with a shipping choice, then payment with a live order total.

Try pressing Next with an empty field — navigation is blocked, the step marker turns red, and the field explains itself once you have touched it.

Form wizard with 3 steps. Currently on step 1.

Checkout

Three steps, about a minute

Step 1 of 3: Contact

How can we reach you?

We will send the receipt and delivery updates here.

What this shows

TechniqueWhere
Per-step validation with a schemazodValidator(contactSchema, { pick: [...] })
Two rules on one stepcomposeValidators(...) on the payment step
Data owned by the wizardupdateData() through a ref, no separate form state
Errors that wait for a blurthe touched map in Field
Derived UI from wizard datathe order summary reads data.shipping
A real completion stateonComplete swaps the wizard for a receipt

Using real Zod

The demo hand-rolls its safeParse objects so the docs stay dependency-free. With zod installed, the shape is identical — only the schema definition changes:

import { z } from "zod";

const contactSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  phone: z.string().min(7, "Enter a phone number we can reach you on"),
});

// unchanged:
validate: zodValidator(contactSchema, { pick: ["email", "phone"] });

Using react-hook-form instead

If react-hook-form already owns your inputs, swap the adapter and keep everything else:

const form = useForm({ resolver: zodResolver(schema), mode: "onChange" });

validate: hookFormValidator(form, { fields: ["email", "phone"] });

// handleSubmit returns an event handler, so invoke it:
onComplete={() => void form.handleSubmit(submit)()}

See the validation guide for both adapters in full.

Notes for production

  • Never post card details from a page like this. Use your payment provider's hosted fields or SDK; the card input here is a placeholder.
  • Recompute totals on the server. Anything derived in the browser is a display convenience, not a source of truth.
  • Consider persist so a refresh mid-checkout does not lose the basket.

Full source

The demo above is this code — the page embeds the component file verbatim, so what you read is what is running.

CheckoutExample.tsx
"use client";

import React from "react";
import FormWizard, {
  composeValidators,
  zodValidator,
  type FormWizardMethods,
  type FormWizardSchema,
  type WizardData,
} from "react-form-wizard-component";
import "react-form-wizard-component/styles.css";
import s from "./examples.module.css";

/* ------------------------------------------------------------------ *
 * Cart — in a real app this comes from your cart state or the server.
 * ------------------------------------------------------------------ */

const CART = [
  { id: "kb", name: "Mechanical keyboard", qty: 1, price: 149 },
  { id: "cable", name: "USB-C cable (2m)", qty: 2, price: 12 },
];

const SHIPPING = {
  standard: { label: "Standard", meta: "4–6 business days", price: 0 },
  express: { label: "Express", meta: "1–2 business days", price: 14 },
} as const;

type ShippingId = keyof typeof SHIPPING;

const money = (n: number) => `$${n.toFixed(2)}`;

/* ------------------------------------------------------------------ *
 * Validation. Written as `safeParse` objects so this demo stays
 * dependency-free — with zod installed these are `z.object({ ... })`
 * and the wizard code below does not change at all.
 * ------------------------------------------------------------------ */

const issue = (message: string) =>
  ({ success: false, error: { issues: [{ message }] } }) as const;
const ok = { success: true } as const;

const contactSchema = {
  safeParse: (v: unknown) => {
    const { email, phone } = (v ?? {}) as Record<string, unknown>;
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email ?? "")))
      return issue("Enter a valid email address");
    if (String(phone ?? "").replace(/\D/g, "").length < 7)
      return issue("Enter a phone number we can reach you on");
    return ok;
  },
};

const addressSchema = {
  safeParse: (v: unknown) => {
    const { street, city, postcode } = (v ?? {}) as Record<string, unknown>;
    if (!String(street ?? "").trim()) return issue("Street address is required");
    if (!String(city ?? "").trim()) return issue("City is required");
    if (String(postcode ?? "").trim().length < 3)
      return issue("Enter a valid postcode");
    return ok;
  },
};

/* ------------------------------------------------------------------ *
 * Field — shows its error only once the field has been touched, so the
 * form does not shout at someone who has not typed anything yet.
 * ------------------------------------------------------------------ */

type FieldProps = {
  label: string;
  value: string;
  onChange: (value: string) => void;
  error?: string;
  touched?: boolean;
  onBlur?: () => void;
  type?: string;
  placeholder?: string;
  autoComplete?: string;
  id: string;
};

function Field({
  label,
  value,
  onChange,
  error,
  touched,
  onBlur,
  type = "text",
  placeholder,
  autoComplete,
  id,
}: FieldProps) {
  const show = Boolean(touched && error);
  return (
    <div className={s.field}>
      <label className={s.label} htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        type={type}
        className={show ? `${s.input} ${s.inputError}` : s.input}
        value={value}
        placeholder={placeholder}
        autoComplete={autoComplete}
        onChange={(e) => onChange(e.target.value)}
        onBlur={onBlur}
        aria-invalid={show || undefined}
        aria-describedby={show ? `${id}-error` : undefined}
      />
      {show && (
        <span className={s.error} id={`${id}-error`} role="alert">
          {error}
        </span>
      )}
    </div>
  );
}

function OrderSummary({ shipping }: { shipping: ShippingId }) {
  const subtotal = CART.reduce((sum, i) => sum + i.price * i.qty, 0);
  const delivery = SHIPPING[shipping].price;
  const tax = Math.round(subtotal * 0.2 * 100) / 100;

  return (
    <div className={s.summary}>
      {CART.map((item) => (
        <div className={s.summaryRow} key={item.id}>
          <span>
            {item.name}
            {item.qty > 1 ? ` × ${item.qty}` : ""}
          </span>
          <span className={s.money}>{money(item.price * item.qty)}</span>
        </div>
      ))}
      <div className={s.summaryRow}>
        <span>{SHIPPING[shipping].label} delivery</span>
        <span className={s.money}>
          {delivery === 0 ? "Free" : money(delivery)}
        </span>
      </div>
      <div className={s.summaryRow}>
        <span>Tax (20%)</span>
        <span className={s.money}>{money(tax)}</span>
      </div>
      <div className={`${s.summaryRow} ${s.summaryTotal}`}>
        <span>Total</span>
        <span className={s.money}>{money(subtotal + delivery + tax)}</span>
      </div>
    </div>
  );
}

export default function CheckoutExample() {
  const wizard = React.useRef<FormWizardMethods>(null);
  const [touched, setTouched] = React.useState<Record<string, boolean>>({});
  const [placed, setPlaced] = React.useState<WizardData | null>(null);

  const set = (patch: WizardData) => wizard.current?.updateData(patch);
  const touch = (name: string) =>
    setTouched((t) => (t[name] ? t : { ...t, [name]: true }));

  const str = (data: WizardData, key: string) => String(data[key] ?? "");

  const schema: FormWizardSchema = {
    initialData: {
      email: "",
      phone: "",
      street: "",
      city: "",
      postcode: "",
      shipping: "standard",
      card: "",
      name: "",
    },
    steps: [
      {
        id: "contact",
        title: "Contact",
        icon: "ti-user",
        content: ({ data }) => (
          <>
            <h4 className={s.stepHeading}>How can we reach you?</h4>
            <p className={s.stepIntro}>
              We will send the receipt and delivery updates here.
            </p>
            <Field
              id="co-email"
              label="Email"
              type="email"
              autoComplete="email"
              placeholder="you@example.com"
              value={str(data, "email")}
              touched={touched.email}
              onBlur={() => touch("email")}
              onChange={(v) => set({ email: v })}
              error={
                /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str(data, "email"))
                  ? undefined
                  : "Enter a valid email address"
              }
            />
            <Field
              id="co-phone"
              label="Phone"
              type="tel"
              autoComplete="tel"
              placeholder="+1 555 0100"
              value={str(data, "phone")}
              touched={touched.phone}
              onBlur={() => touch("phone")}
              onChange={(v) => set({ phone: v })}
              error={
                str(data, "phone").replace(/\D/g, "").length >= 7
                  ? undefined
                  : "Enter a phone number we can reach you on"
              }
            />
          </>
        ),
        // The wizard gates navigation; the fields above only render messages.
        validate: zodValidator(contactSchema, { pick: ["email", "phone"] }),
      },

      {
        id: "delivery",
        title: "Delivery",
        icon: "ti-home",
        content: ({ data }) => (
          <>
            <h4 className={s.stepHeading}>Where is it going?</h4>
            <p className={s.stepIntro}>Delivery address and speed.</p>

            <Field
              id="co-street"
              label="Street address"
              autoComplete="street-address"
              placeholder="12 Example Road"
              value={str(data, "street")}
              touched={touched.street}
              onBlur={() => touch("street")}
              onChange={(v) => set({ street: v })}
              error={str(data, "street").trim() ? undefined : "Street address is required"}
            />

            <div className={s.row}>
              <Field
                id="co-city"
                label="City"
                autoComplete="address-level2"
                placeholder="Bristol"
                value={str(data, "city")}
                touched={touched.city}
                onBlur={() => touch("city")}
                onChange={(v) => set({ city: v })}
                error={str(data, "city").trim() ? undefined : "City is required"}
              />
              <Field
                id="co-postcode"
                label="Postcode"
                autoComplete="postal-code"
                placeholder="BS1 4DJ"
                value={str(data, "postcode")}
                touched={touched.postcode}
                onBlur={() => touch("postcode")}
                onChange={(v) => set({ postcode: v })}
                error={
                  str(data, "postcode").trim().length >= 3
                    ? undefined
                    : "Enter a valid postcode"
                }
              />
            </div>

            <div className={s.choices} role="radiogroup" aria-label="Delivery speed">
              {(Object.keys(SHIPPING) as ShippingId[]).map((id) => {
                const option = SHIPPING[id];
                const selected = data.shipping === id;
                return (
                  <label
                    key={id}
                    className={selected ? `${s.choice} ${s.choiceSelected}` : s.choice}
                  >
                    <input
                      type="radio"
                      name="shipping"
                      checked={selected}
                      onChange={() => set({ shipping: id })}
                    />
                    <span className={s.choiceBody}>
                      <span className={s.choiceTitle}>
                        {option.label} —{" "}
                        {option.price === 0 ? "Free" : money(option.price)}
                      </span>
                      <span className={s.choiceMeta}>{option.meta}</span>
                    </span>
                  </label>
                );
              })}
            </div>
          </>
        ),
        validate: zodValidator(addressSchema, {
          pick: ["street", "city", "postcode"],
        }),
      },

      {
        id: "payment",
        title: "Payment",
        icon: "ti-credit-card",
        content: ({ data }) => (
          <>
            <h4 className={s.stepHeading}>Payment</h4>
            <p className={s.stepIntro}>
              A demo — never send real card details to a page like this.
            </p>

            <OrderSummary shipping={(data.shipping as ShippingId) ?? "standard"} />

            <Field
              id="co-name"
              label="Name on card"
              autoComplete="cc-name"
              placeholder="A. Lovelace"
              value={str(data, "name")}
              touched={touched.name}
              onBlur={() => touch("name")}
              onChange={(v) => set({ name: v })}
              error={str(data, "name").trim() ? undefined : "Name on card is required"}
            />
            <Field
              id="co-card"
              label="Card number"
              autoComplete="cc-number"
              placeholder="4242 4242 4242 4242"
              value={str(data, "card")}
              touched={touched.card}
              onBlur={() => touch("card")}
              onChange={(v) => set({ card: v })}
              error={
                str(data, "card").replace(/\s/g, "").length === 16
                  ? undefined
                  : "Enter the 16 digits on the card"
              }
            />
          </>
        ),
        // Two rules on one step: the schema-shaped check, then a bespoke one.
        validate: composeValidators(
          ({ data }) =>
            String(data.name ?? "").trim() ? true : "Name on card is required",
          ({ data }) =>
            String(data.card ?? "").replace(/\s/g, "").length === 16
              ? true
              : "Enter the 16 digits on the card"
        ),
      },
    ],
  };

  if (placed) {
    return (
      <div className={s.done}>
        <div className={s.doneMark} aria-hidden="true">

        </div>
        <h4>Order placed</h4>
        <p className={s.stepIntro}>
          A receipt is on its way to <strong>{String(placed.email)}</strong>.
        </p>
        <button
          className="btn btn-ghost btn-sm"
          onClick={() => {
            setPlaced(null);
            setTouched({});
          }}
        >
          Run the demo again
        </button>
      </div>
    );
  }

  return (
    <FormWizard
      ref={wizard}
      title="Checkout"
      subtitle="Three steps, about a minute"
      schema={schema}
      shape="circle"
      finishButtonText="Pay now"
      ariaLabel="Checkout"
      // onComplete only fires once the final step validates.
      onComplete={(data) => setPlaced(data ?? {})}
    />
  );
}

The shared examples.module.css only styles the form fields; the wizard chrome comes from react-form-wizard-component/styles.css.

On this page