Booking wizard
Party size, then availability, then contact — with unavailable slots shown rather than hidden.
Live preview
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.
Related
All formsRestaurant starter
A hospitality site whose menu is real, searchable text with dietary tags — never a PDF — with booking above everything else.
starter4 variantsDate field
Native date, time, month and datetime entry on the shared control surface.
starter6 variantsOnboarding wizard
Four steps ordered to minimise abandonment, with a skippable invite step and a review screen.
advancedFeatured4 variants