Checkout
Move through one screen in steps: Shipping → Payment → Review → Done. Everything entered in earlier steps is shown together at the end.
Key points
- 1
Track the current step with a single number
1 = Shipping, 2 = Payment, 3 = Review, 4 = Done. Use this number to decide which screen to show. “Next” adds 1, “Back” subtracts 1.
- 2
Check only the current step before moving on
Keep the validation for all steps in one schema, and when “Next” is pressed, check only the fields on the current step (trigger). If anything is wrong, don’t move forward.
- 3
Show progress at the top
Finished steps get a ✓, the current step is black, and upcoming steps are gray. Knowing how many steps are left makes people less likely to give up halfway.
Tools used
Click to see the comparison in Part 2
Edit the code
Edit the code and the preview on the right updates.
import { useState } from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { Button } from "./components/ui/button" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" // Validation rules (Zod) for every step, written in one schema const schema = z.object({ name: z.string().min(1, "Enter your name"), zip: z.string().regex(/^\d{5}$/, "Enter a 5-digit ZIP code (e.g. 90210)"), // US ZIP code address: z.string().min(1, "Enter your address"), cardNumber: z.string().regex(/^\d{4} ?\d{4} ?\d{4} ?\d{4}$/, "Enter a 16-digit card number"), expiry: z.string().regex(/^\d{2} ?\/ ?\d{2}$/, "Use the MM / YY format (e.g. 12 / 28)"), cvc: z.string().regex(/^\d{3,4}$/, "Enter 3 or 4 digits"), }) // Convert full-width digits and symbols to half-width (so input typed with a Japanese IME still works) function toHalfWidth(value) { return value .replace(/[\uff10-\uff19]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0)) .replace(/[\uff0d\u30fc\u2010]/g, "-") .replace(/\uff0f/g, "/") .replace(/\u3000/g, " ") } // Card number: keep only digits and add a space every 4 digits (4242424242424242 → 4242 4242 4242 4242) function formatCardNumber(value) { const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 16) return digits.match(/.{1,4}/g)?.join(" ") ?? "" } // Expiry: keep only digits and add " / " after the first 2 (1228 → 12 / 28) function formatExpiry(value) { const digits = toHalfWidth(value).replace(/\D/g, "").slice(0, 4) return digits.length > 2 ? digits.slice(0, 2) + " / " + digits.slice(2) : digits } // Add "format as you type" to an input (wraps the result of register) // Rewriting text during IME composition breaks it, so format only after composition ends function withFormat(field, format) { function update(event) { event.target.value = format(event.target.value) field.onChange(event) } return { ...field, onChange: (event) => (event.nativeEvent.isComposing ? field.onChange(event) : update(event)), onCompositionEnd: update, } } // The fields to validate at each step const stepFields = { 1: ["name", "zip", "address"], 2: ["cardNumber", "expiry", "cvc"], } const stepLabels = ["Shipping", "Payment", "Review"] // Group a label, an input, and its error message together function Field({ id, label, error, children }) { return ( <div className="flex flex-col gap-2"> <Label htmlFor={id}>{label}</Label> {children} {error && <p className="text-sm text-destructive">{error}</p>} </div> ) } // The step indicator at the top (1 Shipping — 2 Payment — 3 Review) function StepBar({ step }) { return ( <div className="mb-6 flex items-center gap-2 text-xs"> {stepLabels.map((label, index) => { const number = index + 1 return ( <div key={label} className="flex flex-1 items-center gap-2 last:flex-none"> <span // Finished and current steps (number <= step) are dark; upcoming steps are gray className={number <= step ? "bg-primary text-primary-foreground flex size-6 shrink-0 items-center justify-center rounded-full" : "flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"} > {/* Finished steps (number < step) show ✓; others show the number */} {number < step ? "✓" : number} </span> <span className="whitespace-nowrap">{label}</span> {number < stepLabels.length && <span className="h-px flex-1 bg-border" />} </div> ) })} </div> ) } export default function App() { // ① The current step (1 = Shipping, 2 = Payment, 3 = Review, 4 = Done) const [step, setStep] = useState(1) const { register, trigger, getValues, setValue, reset, formState: { errors } } = useForm({ resolver: zodResolver(schema), mode: "onChange", }) // "Next": validate the current step's fields, then move on async function goNext() { const ok = await trigger(stepFields[step]) // Validate only this step's fields (errors appear in red) if (!ok) return setStep(step + 1) } // "Back" function goBack() { // Go back to the previous step setStep(step - 1) } // "Place order" function placeOrder() { // Go to the Done step (4) setStep(4) } // "Start over" function restart() { reset() // Clear all inputs // Go back to the first step setStep(1) } // When a 5-digit ZIP code is entered, look up the city and state and fill them in (uses the free Zippopotam service) async function fillAddress(zip) { const digits = toHalfWidth(zip).replace("-", "") // Remove the hyphen, leaving only digits if (!/^\d{5}$/.test(digits)) return // Do nothing unless it is 5 digits const response = await fetch("https://api.zippopotam.us/us/" + digits) if (!response.ok) return // Not found: let the user type it const data = await response.json() if (!data.places) return // If not found, do nothing (the user types the address) const found = data.places[0] setValue("address", found["place name"] + ", " + found["state abbreviation"], { shouldValidate: true }) } // The entered values, shown on the Review step const values = getValues() //console.log(step) return ( <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6 text-card-foreground"> {/* Hide the StepBar when step is 4 */} {step === 4 ? null : <StepBar step={step} />} {/* ① Shipping: shown only when step is 1 */} {step === 1 && ( <div className="flex flex-col gap-4"> <p className="text-lg font-semibold">Shipping</p> <Field id="name" label="Name" error={errors.name?.message}> <Input id="name" {...register("name")} /> </Field> <Field id="zip" label="ZIP code" error={errors.zip?.message}> <Input id="zip" placeholder="90210" inputMode="numeric" {...register("zip", { setValueAs: toHalfWidth, onChange: (event) => fillAddress(event.target.value) })} /> </Field> <Field id="address" label="Address" error={errors.address?.message}> <Input id="address" {...register("address")} /> </Field> <div className="flex justify-end"> <Button type="button" onClick={goNext}>Next</Button> </div> </div> )} {/* ② Payment: shown only when step is 2 */} {step === 2 && ( <div className="flex flex-col gap-4"> <p className="text-lg font-semibold">Payment</p> <Field id="cardNumber" label="Card number" error={errors.cardNumber?.message}> <Input id="cardNumber" placeholder="4242 4242 4242 4242" inputMode="numeric" autoComplete="cc-number" {...withFormat(register("cardNumber"), formatCardNumber)} /> </Field> <div className="grid grid-cols-2 gap-3"> <Field id="expiry" label="Expiry" error={errors.expiry?.message}> <Input id="expiry" placeholder="12 / 28" inputMode="numeric" autoComplete="cc-exp" {...withFormat(register("expiry"), formatExpiry)} /> </Field> <Field id="cvc" label="CVC" error={errors.cvc?.message}> <Input id="cvc" placeholder="123" inputMode="numeric" autoComplete="cc-csc" {...withFormat(register("cvc"), (value) => toHalfWidth(value).replace(/\D/g, "").slice(0, 4))} /> </Field> </div> <p className="text-xs text-muted-foreground">This is a demo. Do not enter a real card number.</p> <div className="flex justify-between"> <Button type="button" variant="outline" onClick={goBack}>Back</Button> <Button type="button" onClick={goNext}>Next</Button> </div> </div> )} {/* ③ Review: shown only when step is 3 */} {step === 3 && ( <div className="flex flex-col gap-4"> <p className="text-lg font-semibold">Review your order</p> <dl className="space-y-3 text-sm"> <div className="flex justify-between gap-4"> <dt className="text-muted-foreground">Shipping</dt> <dd className="text-right"> {values.name} <br />{values.address} {values.zip} </dd> </div> <div className="flex justify-between gap-4"> <dt className="text-muted-foreground">Payment</dt> {/* Show only the last 4 digits of the card number */} <dd>Card ****{values.cardNumber?.slice(-4)}</dd> </div> <div className="flex justify-between border-t pt-3 font-semibold"> <dt>Total</dt> <dd>$240</dd> </div> </dl> <div className="flex justify-between"> <Button type="button" variant="outline" onClick={goBack}>Back</Button> <Button type="button" onClick={placeOrder}>Place order</Button> </div> </div> )} {/* Done: shown only when step is 4 */} {step === 4 && ( <div className="py-6 text-center"> <div className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-xl text-emerald-600">✓</div> <p className="mt-4 text-lg font-semibold">Thank you for your order</p> <p className="mt-1 text-sm text-muted-foreground">Order number: A-20261003-0001</p> <Button type="button" variant="outline" className="mt-6" onClick={restart}> Start over (demo) </Button> </div> )} </div> ) }