Multi-step application
Eligibility first, so nobody completes twenty questions before discovering they cannot apply.
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 { Checkbox, Radio } from '@/components/ui/choice'
import { FileUpload } from '@/components/ui/file-upload'
import { Alert } from '@/components/ui/alert'
import { email, minLength, required, url } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'
/**
* Multi-step application
*
* A longer form split so that no single screen exceeds what fits above the
* fold on a phone. The eligibility step comes first and can end the flow early
* — asking twenty questions before discovering someone is ineligible wastes
* their time and your review capacity.
*/
const steps: WizardStep[] = [
{
id: 'eligibility',
label: 'Eligibility',
description: 'Three quick questions',
fields: {
workAuth: [required('Answer')],
remote: [required('Answer')],
},
render: ({ values, setValue, error }) => (
<>
<Fieldset
legend="Are you authorised to work in the EU or UK?"
name="workAuth"
required
error={error('workAuth')}
>
<Radio
id="auth-yes"
name="workAuth"
value="yes"
label="Yes"
checked={values.workAuth === 'yes'}
onChange={() => setValue('workAuth', 'yes')}
/>
<Radio
id="auth-sponsor"
name="workAuth"
value="sponsor"
label="I would need sponsorship"
checked={values.workAuth === 'sponsor'}
onChange={() => setValue('workAuth', 'sponsor')}
/>
<Radio
id="auth-no"
name="workAuth"
value="no"
label="No"
checked={values.workAuth === 'no'}
onChange={() => setValue('workAuth', 'no')}
/>
</Fieldset>
{values.workAuth === 'sponsor' ? (
<Alert tone="info" title="We can sponsor in the UK">
Sponsorship adds roughly six weeks to the process. It does not affect how we assess the
application.
</Alert>
) : null}
<Fieldset
legend="Are you comfortable working fully remotely?"
name="remote"
required
error={error('remote')}
>
<Radio
id="remote-yes"
name="remote"
value="yes"
label="Yes"
checked={values.remote === 'yes'}
onChange={() => setValue('remote', 'yes')}
/>
<Radio
id="remote-hybrid"
name="remote"
value="hybrid"
label="I would prefer hybrid"
checked={values.remote === 'hybrid'}
onChange={() => setValue('remote', 'hybrid')}
/>
</Fieldset>
</>
),
},
{
id: 'about',
label: 'About you',
description: 'Name and links',
fields: {
name: [required('Name')],
email: [required('Email'), email()],
portfolio: [url()],
},
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')}>
{(field) => (
<Input
{...field}
type="email"
autoComplete="email"
value={values.email ?? ''}
onChange={(e) => setValue('email', e.target.value)}
/>
)}
</Field>
<Field
name="portfolio"
label="Portfolio or repository"
showOptional
error={error('portfolio')}
>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="example.com/work"
value={values.portfolio ?? ''}
onChange={(e) => setValue('portfolio', e.target.value)}
/>
)}
</Field>
</>
),
},
{
id: 'experience',
label: 'Experience',
description: 'One real question',
fields: {
story: [required('Answer'), minLength(80, 'Answer')],
},
render: ({ values, setValue, error }) => (
<>
<Field
name="story"
label="Describe a system you inherited and improved"
required
error={error('story')}
hint="What was wrong, what you changed, and how you knew it worked."
>
{(field) => (
<Textarea
{...field}
rows={8}
value={values.story ?? ''}
onChange={(e) => setValue('story', e.target.value)}
/>
)}
</Field>
<Field name="cv" label="CV" showOptional hint="Optional — the answer above matters more.">
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
accept="application/pdf"
hint="PDF, up to 5 MB"
maxSizeBytes={5 * 1024 * 1024}
/>
)}
</Field>
</>
),
},
{
id: 'submit',
label: 'Submit',
description: 'Confirm and send',
fields: {
consent: [(value) => (value === 'true' ? null : 'Confirm to submit your application.')],
},
render: ({ values, setValue, error }) => (
<>
<dl className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
{[
{ label: 'Name', value: values.name },
{ label: 'Email', value: values.email },
{ label: 'Work authorisation', value: values.workAuth },
{ label: 'Remote', value: values.remote },
{ label: 'Answer length', value: `${values.story?.length ?? 0} characters` },
].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>
<Checkbox
id="application-consent"
name="consent"
label="I am happy for my application to be kept on file for six months"
description="This is a demo — nothing is stored."
checked={values.consent === 'true'}
onChange={(e) => setValue('consent', String(e.target.checked))}
/>
{error('consent') ? (
<p className="-mt-1 text-xs font-medium text-danger">{error('consent')}</p>
) : null}
</>
),
},
]
export default function ApplicationForm() {
return (
<Wizard
title="Apply for the role"
description="Four short steps. Eligibility first, so nobody wastes an afternoon."
steps={steps}
initialValues={{ workAuth: 'yes', remote: 'yes', consent: 'false' }}
submitLabel="Submit application"
successTitle="Application submitted"
/>
)
}
components/blocks/forms/application.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Checkbox, Radio } from '@/components/ui/choice'
import { FileUpload } from '@/components/ui/file-upload'
import { Alert } from '@/components/ui/alert'
import { email, minLength, required, url } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'
/**
* Multi-step application
*
* A longer form split so that no single screen exceeds what fits above the
* fold on a phone. The eligibility step comes first and can end the flow early
* — asking twenty questions before discovering someone is ineligible wastes
* their time and your review capacity.
*/
const steps: WizardStep[] = [
{
id: 'eligibility',
label: 'Eligibility',
description: 'Three quick questions',
fields: {
workAuth: [required('Answer')],
remote: [required('Answer')],
},
render: ({ values, setValue, error }) => (
<>
<Fieldset
legend="Are you authorised to work in the EU or UK?"
name="workAuth"
required
error={error('workAuth')}
>
<Radio
id="auth-yes"
name="workAuth"
value="yes"
label="Yes"
checked={values.workAuth === 'yes'}
onChange={() => setValue('workAuth', 'yes')}
/>
<Radio
id="auth-sponsor"
name="workAuth"
value="sponsor"
label="I would need sponsorship"
checked={values.workAuth === 'sponsor'}
onChange={() => setValue('workAuth', 'sponsor')}
/>
<Radio
id="auth-no"
name="workAuth"
value="no"
label="No"
checked={values.workAuth === 'no'}
onChange={() => setValue('workAuth', 'no')}
/>
</Fieldset>
{values.workAuth === 'sponsor' ? (
<Alert tone="info" title="We can sponsor in the UK">
Sponsorship adds roughly six weeks to the process. It does not affect how we assess the
application.
</Alert>
) : null}
<Fieldset
legend="Are you comfortable working fully remotely?"
name="remote"
required
error={error('remote')}
>
<Radio
id="remote-yes"
name="remote"
value="yes"
label="Yes"
checked={values.remote === 'yes'}
onChange={() => setValue('remote', 'yes')}
/>
<Radio
id="remote-hybrid"
name="remote"
value="hybrid"
label="I would prefer hybrid"
checked={values.remote === 'hybrid'}
onChange={() => setValue('remote', 'hybrid')}
/>
</Fieldset>
</>
),
},
{
id: 'about',
label: 'About you',
description: 'Name and links',
fields: {
name: [required('Name')],
email: [required('Email'), email()],
portfolio: [url()],
},
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')}>
{(field) => (
<Input
{...field}
type="email"
autoComplete="email"
value={values.email ?? ''}
onChange={(e) => setValue('email', e.target.value)}
/>
)}
</Field>
<Field
name="portfolio"
label="Portfolio or repository"
showOptional
error={error('portfolio')}
>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="example.com/work"
value={values.portfolio ?? ''}
onChange={(e) => setValue('portfolio', e.target.value)}
/>
)}
</Field>
</>
),
},
{
id: 'experience',
label: 'Experience',
description: 'One real question',
fields: {
story: [required('Answer'), minLength(80, 'Answer')],
},
render: ({ values, setValue, error }) => (
<>
<Field
name="story"
label="Describe a system you inherited and improved"
required
error={error('story')}
hint="What was wrong, what you changed, and how you knew it worked."
>
{(field) => (
<Textarea
{...field}
rows={8}
value={values.story ?? ''}
onChange={(e) => setValue('story', e.target.value)}
/>
)}
</Field>
<Field name="cv" label="CV" showOptional hint="Optional — the answer above matters more.">
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
accept="application/pdf"
hint="PDF, up to 5 MB"
maxSizeBytes={5 * 1024 * 1024}
/>
)}
</Field>
</>
),
},
{
id: 'submit',
label: 'Submit',
description: 'Confirm and send',
fields: {
consent: [(value) => (value === 'true' ? null : 'Confirm to submit your application.')],
},
render: ({ values, setValue, error }) => (
<>
<dl className="divide-y divide-[var(--color-border-subtle)] rounded-lg border border-line bg-surface">
{[
{ label: 'Name', value: values.name },
{ label: 'Email', value: values.email },
{ label: 'Work authorisation', value: values.workAuth },
{ label: 'Remote', value: values.remote },
{ label: 'Answer length', value: `${values.story?.length ?? 0} characters` },
].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>
<Checkbox
id="application-consent"
name="consent"
label="I am happy for my application to be kept on file for six months"
description="This is a demo — nothing is stored."
checked={values.consent === 'true'}
onChange={(e) => setValue('consent', String(e.target.checked))}
/>
{error('consent') ? (
<p className="-mt-1 text-xs font-medium text-danger">{error('consent')}</p>
) : null}
</>
),
},
]
export default function ApplicationForm() {
return (
<Wizard
title="Apply for the role"
description="Four short steps. Eligibility first, so nobody wastes an afternoon."
steps={steps}
initialValues={{ workAuth: 'yes', remote: 'yes', consent: 'false' }}
submitLabel="Submit application"
successTitle="Application submitted"
/>
)
}
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
A longer form split so no single screen exceeds what fits above the fold on a phone. The eligibility step comes first and can end the flow early — asking twenty questions before discovering someone is ineligible wastes their time and your review capacity.
- The sponsorship notice appears conditionally and explains the consequence rather than blocking.
- The consent step is separate, so it is a deliberate act rather than a checkbox buried at the bottom.
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.
- Eligibility gate
- Conditional sponsorship notice
- Long-form answer
- Consent step
Accessibility
- Conditional messaging
- The sponsorship Alert is a polite live region.
- Per-step validation
- Each step validates only its own fields.
- Review before submit
- The final step summarises what will be sent.
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 formsJob application
One open question instead of a cover letter, with an optional CV and no demographic fields.
starter4 variantsOnboarding wizard
Four steps ordered to minimise abandonment, with a skippable invite step and a review screen.
advancedFeatured4 variantsStepper
Multi-step progress in horizontal and vertical orientations, with state conveyed in words as well as ticks.
starter3 variants