Checkout wizard
Four steps with the order summary repeated on every one.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox, Radio } from '@/components/ui/choice'
import { Alert } from '@/components/ui/alert'
import { Divider } from '@/components/ui/layout'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'
import { email, pattern, required } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'
/**
* Checkout wizard
*
* Contact, delivery, payment, review — with the order summary repeated on
* every step. Hiding the total until the last screen is the most reliable way
* to lose a sale, and the most common checkout mistake.
*/
const basket = [
{ product: products[0], quantity: 1 },
{ product: products[6], quantity: 2 },
].filter((line) => line.product)
const subtotal = basket.reduce(
(total, line) => total + (line.product?.priceCents ?? 0) * line.quantity,
0,
)
const shippingOptions = [
{ id: 'standard', label: 'Standard', detail: '2–4 working days', cents: 0 },
{ id: 'express', label: 'Express', detail: 'Next working day', cents: 1200 },
]
function Summary({ shipping }: { shipping: string }) {
const shippingCost = shippingOptions.find((option) => option.id === shipping)?.cents ?? 0
const tax = Math.round((subtotal + shippingCost) * 0.2)
return (
<div className="rounded-lg border border-line bg-surface-sunken p-4">
<h4 className="label-caps mb-3 text-ink-subtle">Order summary</h4>
<ul className="flex flex-col gap-2">
{basket.map((line) => (
<li
key={line.product?.slug}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 text-ink">
{line.product?.name}
<span className="text-ink-subtle"> × {line.quantity}</span>
</span>
<span className="shrink-0 font-mono text-ink tabular-nums">
{formatCurrency((line.product?.priceCents ?? 0) * line.quantity)}
</span>
</li>
))}
</ul>
<Divider className="my-3" weight="subtle" />
<dl className="flex flex-col gap-1.5 text-sm">
<div className="flex justify-between">
<dt className="text-ink-muted">Subtotal</dt>
<dd className="font-mono text-ink tabular-nums">{formatCurrency(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-muted">Shipping</dt>
<dd className="font-mono text-ink tabular-nums">
{shippingCost === 0 ? 'Free' : formatCurrency(shippingCost)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-muted">VAT (20%)</dt>
<dd className="font-mono text-ink tabular-nums">{formatCurrency(tax)}</dd>
</div>
</dl>
<Divider className="my-3" weight="subtle" />
<div className="flex items-baseline justify-between">
<span className="text-sm font-semibold text-ink-strong">Total</span>
<span className="font-mono text-md font-semibold text-ink-strong tabular-nums">
{formatCurrency(subtotal + shippingCost + tax)}
</span>
</div>
</div>
)
}
const steps: WizardStep[] = [
{
id: 'contact',
label: 'Contact',
description: 'Where to send updates',
fields: { email: [required('Email'), email()] },
render: ({ values, setValue, error }) => (
<>
<Field
name="email"
label="Email"
required
error={error('email')}
hint="Order confirmation and delivery updates."
>
{(field) => (
<Input
{...field}
type="email"
autoComplete="email"
value={values.email ?? ''}
onChange={(e) => setValue('email', e.target.value)}
/>
)}
</Field>
<Checkbox
id="checkout-marketing"
name="marketing"
label="Email me about new arrivals"
checked={values.marketing === 'true'}
onChange={(e) => setValue('marketing', String(e.target.checked))}
/>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
{
id: 'delivery',
label: 'Delivery',
description: 'Address and speed',
fields: {
name: [required('Full name')],
line1: [required('Address')],
city: [required('City')],
postcode: [
required('Postcode'),
pattern(
/^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i,
'Enter a valid UK postcode, for example EC2A 4NE.',
),
],
},
render: ({ values, setValue, error }) => (
<>
<Field name="name" label="Full name" required error={error('name')}>
{(field) => (
<Input
{...field}
autoComplete="shipping name"
value={values.name ?? ''}
onChange={(e) => setValue('name', e.target.value)}
/>
)}
</Field>
<Field name="line1" label="Address" required error={error('line1')}>
{(field) => (
<Input
{...field}
autoComplete="shipping address-line1"
value={values.line1 ?? ''}
onChange={(e) => setValue('line1', e.target.value)}
/>
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="city" label="City" required error={error('city')}>
{(field) => (
<Input
{...field}
autoComplete="shipping address-level2"
value={values.city ?? ''}
onChange={(e) => setValue('city', e.target.value)}
/>
)}
</Field>
<Field name="postcode" label="Postcode" required error={error('postcode')}>
{(field) => (
<Input
{...field}
autoComplete="shipping postal-code"
className="font-mono"
value={values.postcode ?? ''}
onChange={(e) => setValue('postcode', e.target.value)}
/>
)}
</Field>
</div>
<Fieldset legend="Delivery speed" name="shipping">
{shippingOptions.map((option) => (
<Radio
key={option.id}
id={`ship-${option.id}`}
name="shipping"
value={option.id}
appearance="card"
label={`${option.label} — ${option.cents === 0 ? 'Free' : formatCurrency(option.cents)}`}
description={option.detail}
checked={(values.shipping ?? 'standard') === option.id}
onChange={() => setValue('shipping', option.id)}
/>
))}
</Fieldset>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
{
id: 'payment',
label: 'Payment',
description: 'Card details',
fields: {
cardholder: [required('Cardholder name')],
cardNumber: [
required('Card number'),
pattern(/^[\d ]{16,23}$/, 'Enter a 16-digit card number.'),
],
cardExpiry: [required('Expiry'), pattern(/^(0[1-9]|1[0-2])\/\d{2}$/, 'Use MM/YY.')],
cardCvc: [required('Security code'), pattern(/^\d{3,4}$/, 'Enter the 3 or 4 digit code.')],
},
render: ({ values, setValue, error }) => (
<>
<Alert tone="warning" title="Never enter a real card">
This checkout has no payment provider behind it. Use 4242 4242 4242 4242.
</Alert>
<Field name="cardholder" label="Name on card" required error={error('cardholder')}>
{(field) => (
<Input
{...field}
autoComplete="cc-name"
value={values.cardholder ?? ''}
onChange={(e) => setValue('cardholder', e.target.value)}
/>
)}
</Field>
<Field name="cardNumber" label="Card number" required error={error('cardNumber')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-number"
className="font-mono"
placeholder="4242 4242 4242 4242"
value={values.cardNumber ?? ''}
onChange={(e) =>
setValue(
'cardNumber',
e.target.value
.replace(/\D/g, '')
.slice(0, 16)
.replace(/(\d{4})(?=\d)/g, '$1 '),
)
}
/>
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="cardExpiry" label="Expiry" required error={error('cardExpiry')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-exp"
className="font-mono"
placeholder="MM/YY"
value={values.cardExpiry ?? ''}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 4)
setValue(
'cardExpiry',
digits.length <= 2 ? digits : `${digits.slice(0, 2)}/${digits.slice(2)}`,
)
}}
/>
)}
</Field>
<Field name="cardCvc" label="Security code" required error={error('cardCvc')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-csc"
className="font-mono"
value={values.cardCvc ?? ''}
onChange={(e) => setValue('cardCvc', e.target.value.replace(/\D/g, '').slice(0, 4))}
/>
)}
</Field>
</div>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
{
id: 'review',
label: 'Review',
description: 'Place the order',
fields: {},
render: ({ values }) => (
<>
<dl className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
{[
{ label: 'Email', value: values.email },
{
label: 'Deliver to',
value: `${values.name}, ${values.line1}, ${values.city} ${values.postcode}`,
},
{
label: 'Speed',
value: shippingOptions.find((o) => o.id === (values.shipping ?? 'standard'))?.label,
},
{
label: 'Card',
value: `•••• ${(values.cardNumber ?? '').replace(/\D/g, '').slice(-4) || '••••'}`,
},
].map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-4 px-4 py-2.5">
<dt className="label-caps shrink-0 text-ink-subtle">{row.label}</dt>
<dd className="min-w-0 text-right text-sm break-token text-ink">
{row.value || '—'}
</dd>
</div>
))}
</dl>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
]
export default function CheckoutForm() {
return (
<Wizard
title="Checkout"
description="The total is visible on every step, not only the last."
steps={steps}
initialValues={{ shipping: 'standard', marketing: 'false' }}
submitLabel="Place order"
successTitle="Order placed"
successBody={
<p>
This is a template demo. No submission was sent, no payment was taken and no order exists.
</p>
}
/>
)
}
components/blocks/forms/checkout.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox, Radio } from '@/components/ui/choice'
import { Alert } from '@/components/ui/alert'
import { Divider } from '@/components/ui/layout'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'
import { email, pattern, required } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'
/**
* Checkout wizard
*
* Contact, delivery, payment, review — with the order summary repeated on
* every step. Hiding the total until the last screen is the most reliable way
* to lose a sale, and the most common checkout mistake.
*/
const basket = [
{ product: products[0], quantity: 1 },
{ product: products[6], quantity: 2 },
].filter((line) => line.product)
const subtotal = basket.reduce(
(total, line) => total + (line.product?.priceCents ?? 0) * line.quantity,
0,
)
const shippingOptions = [
{ id: 'standard', label: 'Standard', detail: '2–4 working days', cents: 0 },
{ id: 'express', label: 'Express', detail: 'Next working day', cents: 1200 },
]
function Summary({ shipping }: { shipping: string }) {
const shippingCost = shippingOptions.find((option) => option.id === shipping)?.cents ?? 0
const tax = Math.round((subtotal + shippingCost) * 0.2)
return (
<div className="rounded-lg border border-line bg-surface-sunken p-4">
<h4 className="label-caps mb-3 text-ink-subtle">Order summary</h4>
<ul className="flex flex-col gap-2">
{basket.map((line) => (
<li
key={line.product?.slug}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 text-ink">
{line.product?.name}
<span className="text-ink-subtle"> × {line.quantity}</span>
</span>
<span className="shrink-0 font-mono text-ink tabular-nums">
{formatCurrency((line.product?.priceCents ?? 0) * line.quantity)}
</span>
</li>
))}
</ul>
<Divider className="my-3" weight="subtle" />
<dl className="flex flex-col gap-1.5 text-sm">
<div className="flex justify-between">
<dt className="text-ink-muted">Subtotal</dt>
<dd className="font-mono text-ink tabular-nums">{formatCurrency(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-muted">Shipping</dt>
<dd className="font-mono text-ink tabular-nums">
{shippingCost === 0 ? 'Free' : formatCurrency(shippingCost)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-muted">VAT (20%)</dt>
<dd className="font-mono text-ink tabular-nums">{formatCurrency(tax)}</dd>
</div>
</dl>
<Divider className="my-3" weight="subtle" />
<div className="flex items-baseline justify-between">
<span className="text-sm font-semibold text-ink-strong">Total</span>
<span className="font-mono text-md font-semibold text-ink-strong tabular-nums">
{formatCurrency(subtotal + shippingCost + tax)}
</span>
</div>
</div>
)
}
const steps: WizardStep[] = [
{
id: 'contact',
label: 'Contact',
description: 'Where to send updates',
fields: { email: [required('Email'), email()] },
render: ({ values, setValue, error }) => (
<>
<Field
name="email"
label="Email"
required
error={error('email')}
hint="Order confirmation and delivery updates."
>
{(field) => (
<Input
{...field}
type="email"
autoComplete="email"
value={values.email ?? ''}
onChange={(e) => setValue('email', e.target.value)}
/>
)}
</Field>
<Checkbox
id="checkout-marketing"
name="marketing"
label="Email me about new arrivals"
checked={values.marketing === 'true'}
onChange={(e) => setValue('marketing', String(e.target.checked))}
/>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
{
id: 'delivery',
label: 'Delivery',
description: 'Address and speed',
fields: {
name: [required('Full name')],
line1: [required('Address')],
city: [required('City')],
postcode: [
required('Postcode'),
pattern(
/^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i,
'Enter a valid UK postcode, for example EC2A 4NE.',
),
],
},
render: ({ values, setValue, error }) => (
<>
<Field name="name" label="Full name" required error={error('name')}>
{(field) => (
<Input
{...field}
autoComplete="shipping name"
value={values.name ?? ''}
onChange={(e) => setValue('name', e.target.value)}
/>
)}
</Field>
<Field name="line1" label="Address" required error={error('line1')}>
{(field) => (
<Input
{...field}
autoComplete="shipping address-line1"
value={values.line1 ?? ''}
onChange={(e) => setValue('line1', e.target.value)}
/>
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="city" label="City" required error={error('city')}>
{(field) => (
<Input
{...field}
autoComplete="shipping address-level2"
value={values.city ?? ''}
onChange={(e) => setValue('city', e.target.value)}
/>
)}
</Field>
<Field name="postcode" label="Postcode" required error={error('postcode')}>
{(field) => (
<Input
{...field}
autoComplete="shipping postal-code"
className="font-mono"
value={values.postcode ?? ''}
onChange={(e) => setValue('postcode', e.target.value)}
/>
)}
</Field>
</div>
<Fieldset legend="Delivery speed" name="shipping">
{shippingOptions.map((option) => (
<Radio
key={option.id}
id={`ship-${option.id}`}
name="shipping"
value={option.id}
appearance="card"
label={`${option.label} — ${option.cents === 0 ? 'Free' : formatCurrency(option.cents)}`}
description={option.detail}
checked={(values.shipping ?? 'standard') === option.id}
onChange={() => setValue('shipping', option.id)}
/>
))}
</Fieldset>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
{
id: 'payment',
label: 'Payment',
description: 'Card details',
fields: {
cardholder: [required('Cardholder name')],
cardNumber: [
required('Card number'),
pattern(/^[\d ]{16,23}$/, 'Enter a 16-digit card number.'),
],
cardExpiry: [required('Expiry'), pattern(/^(0[1-9]|1[0-2])\/\d{2}$/, 'Use MM/YY.')],
cardCvc: [required('Security code'), pattern(/^\d{3,4}$/, 'Enter the 3 or 4 digit code.')],
},
render: ({ values, setValue, error }) => (
<>
<Alert tone="warning" title="Never enter a real card">
This checkout has no payment provider behind it. Use 4242 4242 4242 4242.
</Alert>
<Field name="cardholder" label="Name on card" required error={error('cardholder')}>
{(field) => (
<Input
{...field}
autoComplete="cc-name"
value={values.cardholder ?? ''}
onChange={(e) => setValue('cardholder', e.target.value)}
/>
)}
</Field>
<Field name="cardNumber" label="Card number" required error={error('cardNumber')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-number"
className="font-mono"
placeholder="4242 4242 4242 4242"
value={values.cardNumber ?? ''}
onChange={(e) =>
setValue(
'cardNumber',
e.target.value
.replace(/\D/g, '')
.slice(0, 16)
.replace(/(\d{4})(?=\d)/g, '$1 '),
)
}
/>
)}
</Field>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="cardExpiry" label="Expiry" required error={error('cardExpiry')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-exp"
className="font-mono"
placeholder="MM/YY"
value={values.cardExpiry ?? ''}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 4)
setValue(
'cardExpiry',
digits.length <= 2 ? digits : `${digits.slice(0, 2)}/${digits.slice(2)}`,
)
}}
/>
)}
</Field>
<Field name="cardCvc" label="Security code" required error={error('cardCvc')}>
{(field) => (
<Input
{...field}
inputMode="numeric"
autoComplete="cc-csc"
className="font-mono"
value={values.cardCvc ?? ''}
onChange={(e) => setValue('cardCvc', e.target.value.replace(/\D/g, '').slice(0, 4))}
/>
)}
</Field>
</div>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
{
id: 'review',
label: 'Review',
description: 'Place the order',
fields: {},
render: ({ values }) => (
<>
<dl className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
{[
{ label: 'Email', value: values.email },
{
label: 'Deliver to',
value: `${values.name}, ${values.line1}, ${values.city} ${values.postcode}`,
},
{
label: 'Speed',
value: shippingOptions.find((o) => o.id === (values.shipping ?? 'standard'))?.label,
},
{
label: 'Card',
value: `•••• ${(values.cardNumber ?? '').replace(/\D/g, '').slice(-4) || '••••'}`,
},
].map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-4 px-4 py-2.5">
<dt className="label-caps shrink-0 text-ink-subtle">{row.label}</dt>
<dd className="min-w-0 text-right text-sm break-token text-ink">
{row.value || '—'}
</dd>
</div>
))}
</dl>
<Summary shipping={values.shipping ?? 'standard'} />
</>
),
},
]
export default function CheckoutForm() {
return (
<Wizard
title="Checkout"
description="The total is visible on every step, not only the last."
steps={steps}
initialValues={{ shipping: 'standard', marketing: 'false' }}
submitLabel="Place order"
successTitle="Order placed"
successBody={
<p>
This is a template demo. No submission was sent, no payment was taken and no order exists.
</p>
}
/>
)
}
components/blocks/forms/_wizard.tsx
'use client'
import { useCallback, useRef, useState, type ReactNode } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Stepper, type Step } from '@/components/ui/stepper'
import { runValidators, type Validator } from '@/lib/validation'
/**
* Wizard
*
* Shared machinery for the five multi-step flows. A wizard adds three problems
* a single-page form does not have, and all three are solved here:
*
* 1. Validation is per step — advancing must not validate fields the user
* has not reached yet.
* 2. Focus must move to the new step heading on advance, or a screen-reader
* user has no idea the page changed.
* 3. Going back must never discard what was already entered.
*
* Prefixed with `_` so the registry treats it as a helper, not a preview.
*/
export interface WizardStep extends Step {
fields: Record<string, Validator[]>
render: (helpers: {
values: Record<string, string>
errors: Record<string, string | null>
setValue: (name: string, value: string) => void
error: (name: string) => string | null
}) => ReactNode
}
export interface WizardProps {
title: string
description?: string
steps: WizardStep[]
initialValues?: Record<string, string>
submitLabel: string
successTitle: string
successBody?: ReactNode
orientation?: 'horizontal' | 'vertical'
}
export function Wizard({
title,
description,
steps,
initialValues = {},
submitLabel,
successTitle,
successBody,
orientation = 'horizontal',
}: WizardProps) {
const [index, setIndex] = useState(0)
const [values, setValues] = useState<Record<string, string>>(initialValues)
const [errors, setErrors] = useState<Record<string, string | null>>({})
const [submitting, setSubmitting] = useState(false)
const [done, setDone] = useState(false)
const [announcement, setAnnouncement] = useState('')
const headingRef = useRef<HTMLHeadingElement>(null)
const formRef = useRef<HTMLFormElement>(null)
const step = steps[index]
const setValue = useCallback((name: string, value: string) => {
setValues((current) => ({ ...current, [name]: value }))
setErrors((current) => (current[name] === undefined ? current : { ...current, [name]: null }))
}, [])
const validateStep = useCallback(() => {
if (!step) return {}
const next: Record<string, string | null> = {}
for (const [name, validators] of Object.entries(step.fields)) {
next[name] = runValidators(values[name] ?? '', values, validators)
}
return next
}, [step, values])
const focusHeading = () => {
requestAnimationFrame(() => headingRef.current?.focus())
}
const advance = () => {
const stepErrors = validateStep()
setErrors((current) => ({ ...current, ...stepErrors }))
const invalid = Object.entries(stepErrors).find(([, message]) => message)
if (invalid) {
const control = formRef.current?.querySelector<HTMLElement>(
`[name="${invalid[0]}"], #field-${invalid[0]}`,
)
control?.focus()
setAnnouncement(`${Object.values(stepErrors).filter(Boolean).length} fields need attention.`)
return
}
if (index < steps.length - 1) {
setIndex(index + 1)
setAnnouncement(`Step ${index + 2} of ${steps.length}: ${steps[index + 1]?.label ?? ''}`)
focusHeading()
return
}
setSubmitting(true)
setTimeout(() => {
setSubmitting(false)
setDone(true)
}, 900)
}
const back = () => {
if (index === 0) return
setIndex(index - 1)
setAnnouncement(`Step ${index} of ${steps.length}: ${steps[index - 1]?.label ?? ''}`)
focusHeading()
}
const reset = () => {
setIndex(0)
setValues(initialValues)
setErrors({})
setDone(false)
setAnnouncement('')
}
if (done) {
return (
<div className="mx-auto w-full max-w-2xl">
<div className="flex flex-col items-center rounded-lg border border-success-line bg-success-soft px-6 py-12 text-center">
<CheckCircle2 className="size-8 text-success" aria-hidden="true" />
<h2 className="mt-4 text-md font-semibold text-ink-strong">{successTitle}</h2>
<div className="mt-2 max-w-sm text-sm text-ink-muted">
{successBody ?? <p>This is a template demo. No submission was sent.</p>}
</div>
<Button variant="outline" size="sm" className="mt-6" onClick={reset}>
Start again
</Button>
</div>
</div>
)
}
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<div>
<h2 className="text-lg font-semibold text-ink-strong">{title}</h2>
{description ? <p className="mt-1.5 text-sm text-ink-muted">{description}</p> : null}
</div>
<Stepper
steps={steps}
current={index}
orientation={orientation}
label={`${title} progress`}
/>
<p aria-live="polite" className="sr-only">
{announcement}
</p>
<form
ref={formRef}
onSubmit={(event) => {
event.preventDefault()
advance()
}}
noValidate
className="flex flex-col gap-stack"
aria-busy={submitting || undefined}
>
<h3
ref={headingRef}
tabIndex={-1}
className="text-md font-semibold text-ink-strong focus-visible:outline-2 focus-visible:outline-offset-4"
>
{step?.label}
<span className="ml-2 text-xs font-normal text-ink-subtle">
Step {index + 1} of {steps.length}
</span>
</h3>
{step?.render({
values,
errors,
setValue,
error: (name: string) => errors[name] ?? null,
})}
<div className="flex flex-wrap items-center gap-3 border-t border-line-subtle pt-5">
<Button
type="button"
variant="outline"
onClick={back}
disabled={index === 0 || submitting}
>
Back
</Button>
<Button type="submit" loading={submitting} loadingLabel="Submitting">
{index === steps.length - 1 ? submitLabel : 'Continue'}
</Button>
<Button
type="button"
variant="ghost"
onClick={reset}
disabled={submitting}
className="ml-auto"
>
Start over
</Button>
</div>
<p className="text-xs text-ink-subtle">
Template demo — this wizard validates locally and never sends a request.
</p>
</form>
</div>
)
}
lib/format.ts
/** Small formatting helpers shared across catalogue and starter surfaces. */
export function titleCase(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export function pluralise(count: number, singular: string, plural = `${singular}s`): string {
return `${count} ${count === 1 ? singular : plural}`
}
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
})
export function formatCurrency(cents: number): string {
return currencyFormatter.format(cents / 100)
}
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
})
export function formatCompact(value: number): string {
return compactFormatter.format(value)
}
/**
* Dates in Foundry are authored as ISO date strings so that server and client
* renders agree byte-for-byte. Formatting is pinned to `en-US` + UTC for the
* same reason — no hydration drift from the visitor's locale or timezone.
*/
export function formatDate(iso: string): string {
const date = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}).format(date)
}
export function formatShortDate(iso: string): string {
const date = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}).format(date)
}
export function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return (parts[0] ?? '?').slice(0, 2).toUpperCase()
return `${(parts[0] ?? '')[0] ?? ''}${(parts[parts.length - 1] ?? '')[0] ?? ''}`.toUpperCase()
}
export function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Contact, delivery, payment, review — with the total visible on every step. Hiding the total until the last screen is the most reliable way to lose a sale, and the most common checkout mistake.
- Shipping choice updates the summary immediately, including tax.
- The card number is masked to the last four digits on the review step.
- A prominent warning states no payment provider is involved.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Contact
- Delivery with speed options
- Payment
- Review
- Persistent summary
Accessibility
- Persistent totals
- The summary is repeated rather than linked, so it is never a step away.
- Card masking
- Only the last four digits are echoed back.
- Step announcement
- Progress is announced politely on every advance.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsEcommerce starter
A storefront with ten product routes, five category routes, a working cart with quantity controls and the multi-step checkout from the form catalogue.
advancedFeatured4 variantsPayment method
Card entry with live formatting and a real Luhn checksum.
advancedFeatured4 variantsShipping address
Country-aware postcode validation and autocomplete tokens on every field.
intermediateFeatured3 variants