All blocks
Validated Checkout
A checkout where each step has a Zod schema; validate() inside a beforeStepChange guard blocks invalid input before the review step.
Installation
Add it with the shadcn CLI, open it in v0, or read the source.
$ npx shadcn@latest add https://stepperize.com/r/validated-checkout.jsonDependencies
- @stepperize/react
- lucide-react
- zod
Requirements
- React 18 or later
- Tailwind CSS
Source
import { defineStepper } from "@stepperize/react";
import { Check, CreditCard, MapPin, Pencil } from "lucide-react";
import { useState } from "react";
import { z } from "zod";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// Per-step schemas. `validate()` runs the stored data through these, and the
// `beforeStepChange` guard turns a failed result into blocked navigation.
const shippingSchema = z.object({
name: z.string().min(1, "Name is required"),
address: z.string().min(1, "Address is required"),
zip: z.string().regex(/^\d{5}$/, "Enter a 5-digit ZIP"),
});
const paymentSchema = z.object({
card: z.string().regex(/^\d{16}$/, "Enter a 16-digit card number"),
cvc: z.string().regex(/^\d{3}$/, "3-digit CVC"),
});
const checkout = defineStepper(
[
{ id: "shipping", title: "Shipping", schema: shippingSchema },
{ id: "payment", title: "Payment", schema: paymentSchema },
{ id: "review", title: "Review" },
{ id: "done", title: "Done" },
] as const,
// Seed empty drafts so validate() reports per-field issues from the start
// (an undefined value would fail at the object root instead of each field).
{
defaultData: {
shipping: { name: "", address: "", zip: "" },
payment: { card: "", cvc: "" },
},
},
);
type Errors = Record<string, string>;
/** Read the first issue per field from a failed `validate()` result. */
function toErrors(
issues: ReadonlyArray<{ message: string; path?: ReadonlyArray<unknown> }>,
): Errors {
const out: Errors = {};
for (const issue of issues) {
const seg = issue.path?.[0];
const key =
typeof seg === "object" && seg !== null
? String((seg as { key: PropertyKey }).key)
: String(seg ?? "_");
out[key] ??= issue.message;
}
return out;
}
/**
* Validation + guard rejection: each step carries a Zod schema. Pressing
* Continue runs `ctx.validate()` inside `beforeStepChange`; if it fails, the
* guard returns `false`, the move is cancelled, and the issues are shown inline.
* The review step reads every step's data back with `data.all()`.
*/
export function ValidatedCheckoutBlock() {
const [errors, setErrors] = useState<Errors>({});
return (
<checkout.Stepper.Root
linear
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-sm"
beforeStepChange={async ({ direction, validate }) => {
// Only gate forward moves (next()); Back/Edit should never be blocked.
if (direction !== "next") {
setErrors({});
return true;
}
// Validate the step we're leaving against the transition data snapshot.
const result = await validate();
if (!result.success) {
setErrors(toErrors(result.issues));
return false; // cancel the transition
}
setErrors({});
return true;
}}
>
{({ stepper }) => {
return (
<>
<Stepperline stepper={stepper} />
<div className="mt-6 min-h-44">
<ShippingStep stepper={stepper} errors={errors} />
<PaymentStep stepper={stepper} errors={errors} />
<ReviewStep stepper={stepper} />
<DoneStep />
</div>
{!stepper.is("done") && (
<checkout.Stepper.Actions className="mt-6 flex justify-between">
<checkout.Stepper.Prev className={buttonVariants({ variant: "outline" })}>
Back
</checkout.Stepper.Prev>
<checkout.Stepper.Next className={buttonVariants()}>
{stepper.is("review") ? "Place order" : "Continue"}
</checkout.Stepper.Next>
</checkout.Stepper.Actions>
)}
</>
);
}}
</checkout.Stepper.Root>
);
}
type Stepper = ReturnType<typeof checkout.useStepper>;
function Stepperline({ stepper }: { stepper: Stepper }) {
return (
<checkout.Stepper.List className="flex items-center gap-2">
<checkout.Stepper.Items>
{(step, index) => (
<checkout.Stepper.Item
key={step.id}
step={step.id}
className="flex flex-1 items-center gap-2 last:flex-none"
>
<checkout.Stepper.Indicator className="group grid size-7 shrink-0 place-items-center rounded-full border text-xs font-semibold transition-colors data-[status=active]:border-primary data-[status=active]:bg-primary data-[status=active]:text-primary-foreground data-[status=previous]:border-primary data-[status=previous]:bg-primary/10 data-[status=previous]:text-primary data-[status=upcoming]:border-border data-[status=upcoming]:text-muted-foreground">
<Check className="hidden size-3.5 group-data-[status=previous]:block" />
<span className="group-data-[status=previous]:hidden">
{index + 1}
</span>
</checkout.Stepper.Indicator>
{index < stepper.count - 1 && (
<span className="h-px flex-1 bg-border" />
)}
</checkout.Stepper.Item>
)}
</checkout.Stepper.Items>
</checkout.Stepper.List>
);
}
function Field({
label,
value,
onChange,
error,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
error?: string;
placeholder?: string;
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
aria-invalid={error ? true : undefined}
/>
{error && (
<p className="text-xs font-medium text-destructive">{error}</p>
)}
</div>
);
}
function ShippingStep({
stepper,
errors,
}: {
stepper: Stepper;
errors: Errors;
}) {
const value = stepper.data.get("shipping") ?? {
name: "",
address: "",
zip: "",
};
const set = (patch: Partial<typeof value>) =>
stepper.data.set("shipping", { ...value, ...patch });
return (
<checkout.Stepper.Content step="shipping" className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<MapPin className="size-4 text-primary" /> Where should we ship?
</div>
<Field
label="Full name"
value={value.name}
onChange={(name) => set({ name })}
error={errors.name}
placeholder="Ada Lovelace"
/>
<Field
label="Address"
value={value.address}
onChange={(address) => set({ address })}
error={errors.address}
placeholder="12 Analytical Ave"
/>
<Field
label="ZIP code"
value={value.zip}
onChange={(zip) => set({ zip })}
error={errors.zip}
placeholder="90210"
/>
</checkout.Stepper.Content>
);
}
function PaymentStep({
stepper,
errors,
}: {
stepper: Stepper;
errors: Errors;
}) {
const value = stepper.data.get("payment") ?? { card: "", cvc: "" };
const set = (patch: Partial<typeof value>) =>
stepper.data.set("payment", { ...value, ...patch });
return (
<checkout.Stepper.Content step="payment" className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<CreditCard className="size-4 text-primary" /> Payment details
</div>
<Field
label="Card number"
value={value.card}
onChange={(card) => set({ card })}
error={errors.card}
placeholder="4242424242424242"
/>
<Field
label="CVC"
value={value.cvc}
onChange={(cvc) => set({ cvc })}
error={errors.cvc}
placeholder="123"
/>
<p className="text-xs text-muted-foreground">
Try “1234” to see the guard block the step.
</p>
</checkout.Stepper.Content>
);
}
function ReviewStep({ stepper }: { stepper: Stepper }) {
const all = stepper.data.all();
return (
<checkout.Stepper.Content step="review" className="space-y-3">
<p className="text-sm font-semibold">Review your order</p>
<Summary title="Shipping" onEdit={() => stepper.goTo("shipping")}>
<p>{all.shipping?.name}</p>
<p className="text-muted-foreground">
{all.shipping?.address}, {all.shipping?.zip}
</p>
</Summary>
<Summary title="Payment" onEdit={() => stepper.goTo("payment")}>
<p>•••• •••• •••• {all.payment?.card?.slice(-4)}</p>
</Summary>
</checkout.Stepper.Content>
);
}
function Summary({
title,
onEdit,
children,
}: {
title: string;
onEdit: () => void;
children: React.ReactNode;
}) {
return (
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</span>
<Button
variant="link"
size="xs"
onClick={onEdit}
className="h-auto p-0"
>
<Pencil /> Edit
</Button>
</div>
{children}
</div>
);
}
function DoneStep() {
return (
<checkout.Stepper.Content
step="done"
className="grid place-items-center gap-2 py-6 text-center"
>
<span className="grid size-12 place-items-center rounded-full bg-chart-2/15 text-chart-2">
<Check className="size-6" />
</span>
<p className="text-sm font-medium">Order placed</p>
<p className="text-xs text-muted-foreground">
Every step passed validation before we got here.
</p>
</checkout.Stepper.Content>
);
}
When to use it
The headline checkout pattern — per-step schemas with a guard that refuses to advance until the current step is valid.
Accessibility
Field errors are tied to inputs and surfaced before navigation; the guard prevents skipping past invalid data.
Customization
Each step owns a Zod schema validated via `wizard.validate(id, value)`; swap Zod for any Standard Schema library.
Related blocks
Plan PickerChoose a plan, set billing, then pay — billing path branches on the plan.User OnboardingA three-step account onboarding flow with per-step validation.Dashboard WizardA SaaS settings wizard with sticky step nav, per-step validation, and an unsaved-changes guard wired through beforeStepChange and beforeunload.