Skip to content

Booking wizard

Party size, then availability, then contact — with unavailable slots shown rather than hidden.

BookingadvancedFeaturedbookingreservationavailabilityrestaurant

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, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { DateField } from '@/components/ui/date-field'
import { Badge } from '@/components/ui/badge'
import { email, phone, required } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'

/**
 * Booking wizard
 *
 * Party size first, then date and time, then contact details — the order a
 * restaurant actually needs, because availability depends on the first two.
 *
 * Unavailable slots are rendered as disabled radios rather than being removed,
 * so the user can see that 19:00 exists and is taken, instead of wondering
 * whether the list is complete.
 */
const slots = [
  { time: '17:30', available: true },
  { time: '18:00', available: true },
  { time: '18:30', available: false },
  { time: '19:00', available: false },
  { time: '19:30', available: true },
  { time: '20:00', available: true },
  { time: '20:30', available: true },
  { time: '21:00', available: false },
]

const steps: WizardStep[] = [
  {
    id: 'party',
    label: 'Party',
    description: 'How many, and where',
    fields: { guests: [required('Party size')] },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="guests" label="Number of guests" required error={error('guests')}>
          {(field) => (
            <Select
              {...field}
              value={values.guests ?? '2'}
              onChange={(e) => setValue('guests', e.target.value)}
              options={[1, 2, 3, 4, 5, 6, 7, 8].map((count) => ({
                value: String(count),
                label: count === 1 ? '1 guest' : `${count} guests`,
              }))}
            />
          )}
        </Field>
        <Fieldset legend="Seating" name="seating">
          <Radio
            id="seat-dining"
            name="seating"
            value="dining"
            appearance="card"
            label="Dining room"
            description="Full menu, quieter."
            checked={(values.seating ?? 'dining') === 'dining'}
            onChange={() => setValue('seating', 'dining')}
          />
          <Radio
            id="seat-counter"
            name="seating"
            value="counter"
            appearance="card"
            label="Kitchen counter"
            description="Full menu, watch the pass. Two guests maximum."
            checked={values.seating === 'counter'}
            onChange={() => setValue('seating', 'counter')}
          />
          <Radio
            id="seat-bar"
            name="seating"
            value="bar"
            appearance="card"
            label="Bar"
            description="Snacks only, walk-ins welcome."
            checked={values.seating === 'bar'}
            onChange={() => setValue('seating', 'bar')}
          />
        </Fieldset>
      </>
    ),
  },
  {
    id: 'when',
    label: 'Date & time',
    description: 'Pick a slot',
    fields: {
      date: [required('Date')],
      time: [required('Time')],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="date" label="Date" required error={error('date')}>
          {(field) => (
            <DateField
              {...field}
              min="2026-03-14"
              value={values.date ?? ''}
              onChange={(e) => setValue('date', e.target.value)}
            />
          )}
        </Field>
        <Fieldset
          legend="Time"
          name="time"
          required
          error={error('time')}
          hint="Unavailable slots are shown so you can see the full evening."
        >
          <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
            {slots.map((slot) => (
              <label
                key={slot.time}
                htmlFor={`slot-${slot.time}`}
                className="relative flex min-h-11 cursor-pointer items-center justify-center rounded-md border border-line bg-surface text-sm font-medium text-ink has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:checked]:text-accent-soft-ink has-[:disabled]:cursor-not-allowed has-[:disabled]:bg-surface-sunken has-[:disabled]:text-ink-subtle"
              >
                <input
                  id={`slot-${slot.time}`}
                  type="radio"
                  name="time"
                  value={slot.time}
                  disabled={!slot.available}
                  checked={values.time === slot.time}
                  onChange={() => setValue('time', slot.time)}
                  className="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
                />
                {slot.time}
                {!slot.available ? <span className="sr-only"> — fully booked</span> : null}
              </label>
            ))}
          </div>
        </Fieldset>
      </>
    ),
  },
  {
    id: 'details',
    label: 'Your details',
    description: 'So we can hold the table',
    fields: {
      name: [required('Name')],
      email: [required('Email'), email()],
      phone: [required('Phone'), phone()],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="name" label="Name" required error={error('name')}>
          {(field) => (
            <Input
              {...field}
              autoComplete="name"
              value={values.name ?? ''}
              onChange={(e) => setValue('name', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="email"
          label="Email"
          required
          error={error('email')}
          hint="We send the confirmation here."
        >
          {(field) => (
            <Input
              {...field}
              type="email"
              autoComplete="email"
              value={values.email ?? ''}
              onChange={(e) => setValue('email', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="phone"
          label="Phone"
          required
          error={error('phone')}
          hint="In case we need to reach you on the night."
        >
          {(field) => (
            <Input
              {...field}
              type="tel"
              autoComplete="tel"
              value={values.phone ?? ''}
              onChange={(e) => setValue('phone', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="notes"
          label="Anything we should know?"
          showOptional
          hint="Allergies, celebrations, access requirements."
        >
          {(field) => (
            <Textarea
              {...field}
              rows={3}
              value={values.notes ?? ''}
              onChange={(e) => setValue('notes', e.target.value)}
            />
          )}
        </Field>
      </>
    ),
  },
  {
    id: 'confirm',
    label: 'Confirm',
    description: 'Check and book',
    fields: {},
    render: ({ values }) => (
      <div className="rounded-lg border border-line bg-surface p-5">
        <div className="flex flex-wrap items-center gap-2">
          <Badge tone="accent">Table for {values.guests ?? '2'}</Badge>
          <Badge>{values.seating ?? 'dining'}</Badge>
        </div>
        <p className="display-type mt-3 text-xl font-semibold text-ink-strong">
          {values.date || 'No date selected'} at {values.time || '—'}
        </p>
        <dl className="mt-4 flex flex-col gap-1.5 text-sm">
          <div className="flex gap-2">
            <dt className="text-ink-subtle">Name</dt>
            <dd className="text-ink">{values.name}</dd>
          </div>
          <div className="flex gap-2">
            <dt className="text-ink-subtle">Contact</dt>
            <dd className="break-token text-ink">
              {values.email} · {values.phone}
            </dd>
          </div>
          {values.notes ? (
            <div className="flex gap-2">
              <dt className="shrink-0 text-ink-subtle">Notes</dt>
              <dd className="text-ink">{values.notes}</dd>
            </div>
          ) : null}
        </dl>
        <p className="mt-4 text-xs text-ink-muted">
          Tables are held for 15 minutes past the booking time.
        </p>
      </div>
    ),
  },
]

export default function BookingForm() {
  return (
    <Wizard
      title="Book a table"
      description="Four steps. Availability shown is fictional."
      steps={steps}
      initialValues={{ guests: '2', seating: 'dining' }}
      submitLabel="Confirm booking"
      successTitle="Table booked"
      successBody={
        <p>This is a template demo. No submission was sent and no table was reserved.</p>
      }
    />
  )
}

components/blocks/forms/booking.tsx

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { DateField } from '@/components/ui/date-field'
import { Badge } from '@/components/ui/badge'
import { email, phone, required } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'

/**
 * Booking wizard
 *
 * Party size first, then date and time, then contact details — the order a
 * restaurant actually needs, because availability depends on the first two.
 *
 * Unavailable slots are rendered as disabled radios rather than being removed,
 * so the user can see that 19:00 exists and is taken, instead of wondering
 * whether the list is complete.
 */
const slots = [
  { time: '17:30', available: true },
  { time: '18:00', available: true },
  { time: '18:30', available: false },
  { time: '19:00', available: false },
  { time: '19:30', available: true },
  { time: '20:00', available: true },
  { time: '20:30', available: true },
  { time: '21:00', available: false },
]

const steps: WizardStep[] = [
  {
    id: 'party',
    label: 'Party',
    description: 'How many, and where',
    fields: { guests: [required('Party size')] },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="guests" label="Number of guests" required error={error('guests')}>
          {(field) => (
            <Select
              {...field}
              value={values.guests ?? '2'}
              onChange={(e) => setValue('guests', e.target.value)}
              options={[1, 2, 3, 4, 5, 6, 7, 8].map((count) => ({
                value: String(count),
                label: count === 1 ? '1 guest' : `${count} guests`,
              }))}
            />
          )}
        </Field>
        <Fieldset legend="Seating" name="seating">
          <Radio
            id="seat-dining"
            name="seating"
            value="dining"
            appearance="card"
            label="Dining room"
            description="Full menu, quieter."
            checked={(values.seating ?? 'dining') === 'dining'}
            onChange={() => setValue('seating', 'dining')}
          />
          <Radio
            id="seat-counter"
            name="seating"
            value="counter"
            appearance="card"
            label="Kitchen counter"
            description="Full menu, watch the pass. Two guests maximum."
            checked={values.seating === 'counter'}
            onChange={() => setValue('seating', 'counter')}
          />
          <Radio
            id="seat-bar"
            name="seating"
            value="bar"
            appearance="card"
            label="Bar"
            description="Snacks only, walk-ins welcome."
            checked={values.seating === 'bar'}
            onChange={() => setValue('seating', 'bar')}
          />
        </Fieldset>
      </>
    ),
  },
  {
    id: 'when',
    label: 'Date & time',
    description: 'Pick a slot',
    fields: {
      date: [required('Date')],
      time: [required('Time')],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="date" label="Date" required error={error('date')}>
          {(field) => (
            <DateField
              {...field}
              min="2026-03-14"
              value={values.date ?? ''}
              onChange={(e) => setValue('date', e.target.value)}
            />
          )}
        </Field>
        <Fieldset
          legend="Time"
          name="time"
          required
          error={error('time')}
          hint="Unavailable slots are shown so you can see the full evening."
        >
          <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
            {slots.map((slot) => (
              <label
                key={slot.time}
                htmlFor={`slot-${slot.time}`}
                className="relative flex min-h-11 cursor-pointer items-center justify-center rounded-md border border-line bg-surface text-sm font-medium text-ink has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:checked]:text-accent-soft-ink has-[:disabled]:cursor-not-allowed has-[:disabled]:bg-surface-sunken has-[:disabled]:text-ink-subtle"
              >
                <input
                  id={`slot-${slot.time}`}
                  type="radio"
                  name="time"
                  value={slot.time}
                  disabled={!slot.available}
                  checked={values.time === slot.time}
                  onChange={() => setValue('time', slot.time)}
                  className="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
                />
                {slot.time}
                {!slot.available ? <span className="sr-only"> — fully booked</span> : null}
              </label>
            ))}
          </div>
        </Fieldset>
      </>
    ),
  },
  {
    id: 'details',
    label: 'Your details',
    description: 'So we can hold the table',
    fields: {
      name: [required('Name')],
      email: [required('Email'), email()],
      phone: [required('Phone'), phone()],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="name" label="Name" required error={error('name')}>
          {(field) => (
            <Input
              {...field}
              autoComplete="name"
              value={values.name ?? ''}
              onChange={(e) => setValue('name', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="email"
          label="Email"
          required
          error={error('email')}
          hint="We send the confirmation here."
        >
          {(field) => (
            <Input
              {...field}
              type="email"
              autoComplete="email"
              value={values.email ?? ''}
              onChange={(e) => setValue('email', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="phone"
          label="Phone"
          required
          error={error('phone')}
          hint="In case we need to reach you on the night."
        >
          {(field) => (
            <Input
              {...field}
              type="tel"
              autoComplete="tel"
              value={values.phone ?? ''}
              onChange={(e) => setValue('phone', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="notes"
          label="Anything we should know?"
          showOptional
          hint="Allergies, celebrations, access requirements."
        >
          {(field) => (
            <Textarea
              {...field}
              rows={3}
              value={values.notes ?? ''}
              onChange={(e) => setValue('notes', e.target.value)}
            />
          )}
        </Field>
      </>
    ),
  },
  {
    id: 'confirm',
    label: 'Confirm',
    description: 'Check and book',
    fields: {},
    render: ({ values }) => (
      <div className="rounded-lg border border-line bg-surface p-5">
        <div className="flex flex-wrap items-center gap-2">
          <Badge tone="accent">Table for {values.guests ?? '2'}</Badge>
          <Badge>{values.seating ?? 'dining'}</Badge>
        </div>
        <p className="display-type mt-3 text-xl font-semibold text-ink-strong">
          {values.date || 'No date selected'} at {values.time || '—'}
        </p>
        <dl className="mt-4 flex flex-col gap-1.5 text-sm">
          <div className="flex gap-2">
            <dt className="text-ink-subtle">Name</dt>
            <dd className="text-ink">{values.name}</dd>
          </div>
          <div className="flex gap-2">
            <dt className="text-ink-subtle">Contact</dt>
            <dd className="break-token text-ink">
              {values.email} · {values.phone}
            </dd>
          </div>
          {values.notes ? (
            <div className="flex gap-2">
              <dt className="shrink-0 text-ink-subtle">Notes</dt>
              <dd className="text-ink">{values.notes}</dd>
            </div>
          ) : null}
        </dl>
        <p className="mt-4 text-xs text-ink-muted">
          Tables are held for 15 minutes past the booking time.
        </p>
      </div>
    ),
  },
]

export default function BookingForm() {
  return (
    <Wizard
      title="Book a table"
      description="Four steps. Availability shown is fictional."
      steps={steps}
      initialValues={{ guests: '2', seating: 'dining' }}
      submitLabel="Confirm booking"
      successTitle="Table booked"
      successBody={
        <p>This is a template demo. No submission was sent and no table was reserved.</p>
      }
    />
  )
}

components/ui/date-field.tsx

import type { InputHTMLAttributes } from 'react'
import { Calendar } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'

/**
 * DateField
 *
 * A native `type="date"` control with Foundry chrome. Building a custom date
 * picker means re-implementing locale formats, keyboard grids and screen-reader
 * announcements — the native control already ships all of that, plus the
 * platform picker on mobile.
 *
 * The only additions are the shared control surface and a decorative icon,
 * hidden on WebKit where the engine draws its own indicator.
 */
export interface DateFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
  fieldSize?: 'sm' | 'md' | 'lg'
  /** `time` and `datetime-local` share identical chrome. */
  kind?: 'date' | 'time' | 'datetime-local' | 'month'
}

const heights = {
  sm: 'h-control-sm text-xs',
  md: 'h-control text-sm',
  lg: 'h-control-lg text-base',
} as const

export function DateField({
  fieldSize = 'md',
  kind = 'date',
  className,
  ...props
}: DateFieldProps) {
  return (
    <div className="relative flex items-center">
      <input
        type={kind}
        className={cn(
          controlSurface,
          heights[fieldSize],
          'px-3',
          '[&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-60',
          '[&::-webkit-calendar-picker-indicator]:hover:opacity-100',
          className,
        )}
        {...props}
      />
      <Calendar
        className="pointer-events-none absolute right-3 size-4 text-ink-subtle [@supports(-webkit-appearance:none)]:hidden"
        aria-hidden="true"
      />
    </div>
  )
}

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>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Ordered the way a restaurant actually needs, because availability depends on party size and seating. Unavailable slots are disabled rather than removed, so the user can see that 19:00 exists and is taken.

  • Disabled slots carry a visually hidden “fully booked” so the reason is available to screen readers.
  • The confirmation step restates everything, including the 15-minute holding policy.
  • Notes are optional and prompted for allergies and access requirements.

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.

  • Seating cards
  • Time-slot grid with disabled slots
  • Contact details
  • Confirmation summary

Accessibility

Visible unavailability
Taken slots remain in the DOM, disabled and labelled.
Radio grid
The slot grid is a Fieldset of radios, so it is one tab stop with arrow-key movement.
Step focus
Advancing moves focus to the step heading.

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