Skip to content

Checkout wizard

Four steps with the order summary repeated on every one.

CommerceadvancedFeaturedcheckoutcommercewizardsummarypayment

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.

All forms