Skip to content

Multi-step application

Eligibility first, so nobody completes twenty questions before discovering they cannot apply.

Onboardingadvancedwizardapplicationeligibilityhiring

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 { 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.

All forms