Checkout Form
A one-page checkout — contact, delivery address, payment method and a summary whose tax, delivery and fees are derived from the form in the same render, so the money can never lag the address.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/checkout-form.jsonPrompt
Build a React + TypeScript + Tailwind "CheckoutForm" block (zod, lucide-react; reuses
detectCardBrand / isLuhnValid from a credit-card-form module). One page, not a wizard:
the order summary has to react to the address and to the payment method, and putting
those on separate screens hides exactly the coupling that goes wrong.
Contract
- CheckoutForm props: cart, onSubmit, defaultValues?, countries?, defaultCountry?,
methods?, quote?, locale = "en-US", now?, disabled?, messages?. forwardRef to the form.
- cart: { currency (ISO 4217), items: { id, name, variant?, quantity, unitAmount }[],
discount?: { code?, label, amount } }.
- EVERY amount is an integer in the currency's MINOR UNIT (cents for USD, yen for JPY,
fils for KWD). Never floats, never toFixed(2). Two reasons: 19.99 * 3 is
59.96999999999999 in binary floating point, and the number of decimals belongs to the
currency, not the layout — toFixed(2) invents a JPY price with decimal places.
- Formatting: one Intl.NumberFormat(locale, { style: "currency", currency }) per currency;
take the divisor from format.resolvedOptions().maximumFractionDigits (2 / 0 / 3), never
a hard-coded 100. Normalise -0 so a zero line prints "$0.00", not "-$0.00". Render a
discount as format(-amount) so Intl picks the locale's sign convention instead of a
hand-written minus. Wrap the constructor in try/catch: a bad currency code out of a data
feed must not blank the checkout.
- countries: one row per destination carrying BOTH the address shape (field order, row
labels, an optional region rule with an options list, a postcode rule with a regex) AND
the money (tax { label, rateBps, regionBps?, taxable: "goods" | "all" }, shipping
{ label, flat, freeOver?, remote? { label, pattern, amount } }). One table, because the
address and the price are answers to the same question.
- quote(input) returns { subtotal, shipping, shippingLabel, fee, feeLabel?, tax, taxLabel,
discount }. It must NOT return a total. The component computes
total = subtotal + shipping + fee + tax − discount itself, which is what makes "the lines
add up to the button" structural instead of a rule to remember.
- onSubmit(payload) returns void or a Promise. payload = { email, shippingAddress, payment
(a discriminated union on "card" | "wallet" | "invoice"), totals }. Reject to fail;
attach a fieldErrors object to the rejection to blame specific rows.
Behavior
- Totals are DERIVED DURING RENDER from the form state — never stored in state and
refreshed from an effect. That is the entire anti-tearing design: the new country and
the new tax land in the same commit, so no painted frame can show a new address beside
an old price. If you replace quote with an async service call, do not await it into the
same state: keep the answer keyed by the address it was computed for and render a
"calculated at the next step" placeholder whenever the key does not match what is on
screen. A stale number is worse than no number.
- The postcode moves the price too, not just the country: a remote-area surcharge is
matched against the trimmed, upper-cased, space-stripped code, and it survives the
free-delivery threshold because the carrier still bills it.
- Payment method changes both the fields and the money: card mounts number / expiry / CVC /
name, wallet mounts nothing and relabels the button, pay-by-invoice mounts company plus
VAT ID and adds a percentage fee row that VAT countries then tax. Switching clears the
errors written against the old field set.
- Values are scoped to what is on screen on every render (a country with no administrative
area drops region; leaving "card" drops the card number), so a hidden row can never
smuggle a stale value into the payload. Switching country keeps a subdivision only if the
new country's own list contains it, and keeps a postcode only if it still validates.
- Validation is zod ONLY, rebuilt whenever the country or method changes because both
change what "valid" means. The form carries noValidate so the browser never competes;
type="email" survives for the keyboard. Every superRefine branch guards its own inputs —
refinements all run, and one that assumed a well-formed value would throw out of
safeParse instead of returning an error object.
- Card number: brand-aware grouping and maxLength, Luhn as an error message, caret held by
"how many digits are behind it" so a mid-string edit does not fling it to the end, and
Backspace/Delete reach over a separator the component drew itself. Expiry is MM/YY with a
real "is it in the past" rule against an injectable now.
- Duplicate submits are stopped by a REF read and written synchronously at the top of the
handler, not by state: five presses inside one tick all read the same stale state and the
customer is charged five times. The ref stays engaged after a success — a payment that
went through must not be submittable again.
- The pay button is aria-disabled, never natively disabled: the browser blurs a control the
instant it becomes disabled, and the button has to stay focusable to take the handover
when the fields disable themselves mid-flight.
- Failure clears NOTHING — not the address, not the card number. A decline is usually
retried with the same details. With field errors, focus moves to the first blamed row ON
THE NEXT COMMIT, because the commit that reports the error is also the commit that
re-enables the fields, and focus() on a still-disabled control fails silently. Verify
focus landed (document.activeElement === node) instead of assuming it did. With no field
to blame, focus stays on the pay button, which is aria-describedby the alert banner.
- The full card number never appears anywhere but its own input: no summary line, no live
region, no title, no data attribute, and no name attribute on any card input, so a stray
native submit cannot post it. CVC refuses paste (a pasted code was stored somewhere it
should not have been) while the number field still accepts one. The polite live region
carries the detected brand and the code length — no digits.
- An empty basket refuses the press and says why, instead of authorising a payment of
nothing. Quantities and prices are clamped (non-finite to 0, negative or fractional
quantities to whole and non-negative) and a discount larger than the basket is clamped to
it, so the discount printed is always the discount subtracted. "Empty" is counted in
UNITS, not line entries — quote receives itemCount as the sum of the clamped quantities —
so a basket whose only line has fallen to 0 is empty, and is never quoted delivery, a
percentage fee, or the tax on top of them.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / bg-muted / text-muted-foreground /
border / border-input / bg-primary / text-primary-foreground / text-destructive /
ring-ring / accent-primary. No hex, no rgb(), no oklch().
- Container queries, not viewport media queries: the fields column and the summary sit side
by side at 52rem with the summary sticky, and stack below it. Address rows go two-up at
30rem; the region and postcode are the only narrow ones.
- Money cells are tabular-nums and shrink-0; labels get [overflow-wrap:anywhere] with
min-w-0 so a long product name wraps instead of pushing the page sideways (break-words
alone does not shrink min-content).
- Each field owns a fixed-height message slot, so an error appearing does not push the pay
button out from under a cursor already on its way down.
- Every control has a real label element, aria-invalid and aria-describedby pointing at a
node that exists; the payment picker is native radios in a fieldset; the country and
region pickers are native select elements (a div listbox cannot be filled by address
autofill) with explicit option token colours so the dark-mode popup is readable.
- autocomplete has to be exact or the browser's address and card managers are useless:
email, shipping country / name / address-line1 / address-line2 / address-level2 /
address-level1 / postal-code, cc-number, cc-exp, cc-csc, cc-name, organization.
- transition-colors with motion-reduce:transition-none, and the pending spinner is
motion-reduce:animate-none — the "Processing…" label carries the state when animation
is off.
Customization levers
- Destinations and money: trim or extend countries. Each row is independent — field order,
row labels, region list, postcode regex, tax rate (with per-region overrides), whether
tax applies to delivery, flat delivery, free-delivery threshold, remote-area surcharge.
The built-in rates are illustrative; hand real tax to a tax engine.
- Replace quote outright to price from your own service (see the keyed-answer note above if
it is asynchronous).
- Ways to pay: pass methods to reorder, drop one, or change a fee's basis points and its
row label. submitLabel on a method replaces the "Pay $X" button text.
- Currency and locale: the cart's currency decides the decimals, locale decides grouping
and symbol placement. A 0- or 3-decimal currency needs its own delivery figures in
countries, since those are written in minor units.
- Copy: messages overrides every string outside the country rules; validation wording lives
in buildCheckoutSchema, exported so a server route can run the identical check.
- Layout density: the two-column breakpoint, the address two-up breakpoint and the
summary's sticky offset are single classes. Add a thumbnail column to the line items, or
a phone row with autocomplete `shipping tel`, without touching the totals path.Concepts
- Derived totals — the money is recomputed from the form state during render rather than stored beside it. A country change and its tax land in the same commit, so there is no paintable frame in which the address has moved and the price has not. Tearing only comes back with an asynchronous quote, and the answer to that is to key the quote by the address it was computed for.
- Minor units — every amount is an integer in the currency's smallest unit, and
Intlis asked how many decimals that currency has. This is what keepssubtotal + delivery + fee + tax − discount === totalexact, and what stops a JPY order from printing invented decimal places. - Structural total —
quotereturns the parts and is not allowed to return a total; the component derives it. An implementation that accepts both can print lines that do not add up, and no amount of care prevents it. - Address-driven price — tax comes from the country and, in the US and Canada, the subdivision; delivery comes from the country plus a postcode-matched remote-area surcharge that survives the free-delivery threshold. The postcode is not decoration.
- Synchronous duplicate guard — a ref is read and written before anything can yield, so five presses inside one tick see one another. A state flag cannot: all five read the same stale value, and all five charge the card.
- Deferred error focus — the commit that reports a decline is also the commit that re-enables the fields, so focus is requested and applied on the next commit, and the result is verified rather than assumed.
focus()on a disabled control neither throws nor moves anything.
Search Results
A four-state search results page — query echo, keyword highlighting, faceted filters whose counts are the real post-click result count, and pagination that snaps back to page 1 on every filter change.
Order Confirmation
The page you land on once the money has moved — a dictatable, one-click-copyable order number, an itemised table whose money is refined to add up, a delivery window rather than a promised day, ship / pickup / digital fulfillment, and print styles that survive paper.