Skip to content

Onboarding wizard

Four steps ordered to minimise abandonment, with a skippable invite step and a review screen.

OnboardingadvancedFeaturedwizardonboardingmulti-stepreview

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 { Select } from '@/components/ui/select'
import { email, pattern, required } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'

/**
 * Onboarding wizard
 *
 * Four steps in the order that minimises abandonment: identity, then the
 * workspace, then the optional invite, then a review. The invite step is
 * skippable because forcing it at signup is the single most common reason
 * people abandon onboarding.
 */
const steps: WizardStep[] = [
  {
    id: 'account',
    label: 'Account',
    description: 'Name and email',
    fields: {
      name: [required('Full name')],
      email: [required('Email'), email()],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="name" label="Full 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="Work email" required error={error('email')}>
          {(field) => (
            <Input
              {...field}
              type="email"
              autoComplete="email"
              value={values.email ?? ''}
              onChange={(e) => setValue('email', e.target.value)}
            />
          )}
        </Field>
      </>
    ),
  },
  {
    id: 'workspace',
    label: 'Workspace',
    description: 'Name, slug and region',
    fields: {
      workspace: [required('Workspace name')],
      slug: [
        required('Slug'),
        pattern(/^[a-z0-9-]{3,32}$/, 'Use 3–32 lowercase letters, digits or hyphens.'),
      ],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="workspace" label="Workspace name" required error={error('workspace')}>
          {(field) => (
            <Input
              {...field}
              placeholder="Acme Platform"
              value={values.workspace ?? ''}
              onChange={(e) => setValue('workspace', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="slug"
          label="Workspace URL"
          required
          error={error('slug')}
          hint={`foundry.example.com/${values.slug || 'your-slug'}`}
        >
          {(field) => (
            <Input
              {...field}
              className="font-mono"
              placeholder="acme-platform"
              value={values.slug ?? ''}
              onChange={(e) => setValue('slug', e.target.value)}
            />
          )}
        </Field>
        <Field name="region" label="Data region">
          {(field) => (
            <Select
              {...field}
              value={values.region ?? 'eu-west'}
              onChange={(e) => setValue('region', e.target.value)}
              options={[
                { value: 'eu-west', label: 'EU West (Ireland)' },
                { value: 'us-east', label: 'US East (Virginia)' },
                { value: 'ap-south', label: 'Asia Pacific (Singapore)' },
              ]}
            />
          )}
        </Field>
      </>
    ),
  },
  {
    id: 'team',
    label: 'Invite team',
    description: 'Optional',
    fields: {
      invites: [
        (value) => {
          if (!value.trim()) return null
          const addresses = value
            .split(/[,\n]/)
            .map((entry) => entry.trim())
            .filter(Boolean)
          const invalid = addresses.find(
            (address) => !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(address),
          )
          return invalid ? `“${invalid}” is not a valid email address.` : null
        },
      ],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field
          name="invites"
          label="Invite colleagues"
          showOptional
          error={error('invites')}
          hint="One address per line, or separated by commas. You can skip this and invite people later."
        >
          {(field) => (
            <textarea
              {...field}
              rows={4}
              className="w-full rounded-md border border-line bg-surface px-3 py-2 text-sm"
              value={values.invites ?? ''}
              onChange={(e) => setValue('invites', e.target.value)}
              placeholder={'tomas@acme.com\namara@acme.com'}
            />
          )}
        </Field>
        <Fieldset legend="Default role for invitees" name="role">
          <Radio
            id="role-member"
            name="role"
            value="member"
            label="Member"
            description="Can view and edit projects."
            checked={(values.role ?? 'member') === 'member'}
            onChange={() => setValue('role', 'member')}
          />
          <Radio
            id="role-admin"
            name="role"
            value="admin"
            label="Admin"
            description="Can also manage billing and members."
            checked={values.role === 'admin'}
            onChange={() => setValue('role', 'admin')}
          />
        </Fieldset>
      </>
    ),
  },
  {
    id: 'review',
    label: 'Review',
    description: 'Confirm and finish',
    fields: {
      confirm: [(value) => (value === 'true' ? null : 'Confirm the details to finish.')],
    },
    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: 'Workspace', value: values.workspace },
            { label: 'URL', value: values.slug ? `foundry.example.com/${values.slug}` : '—' },
            { label: 'Region', value: values.region ?? 'eu-west' },
            {
              label: 'Invitations',
              value: values.invites?.trim()
                ? `${values.invites.split(/[,\n]/).filter((entry) => entry.trim()).length} people`
                : 'None',
            },
          ].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="onboarding-confirm"
          name="confirm"
          label="These details are correct"
          checked={values.confirm === 'true'}
          onChange={(e) => setValue('confirm', String(e.target.checked))}
        />
        {error('confirm') ? (
          <p className="-mt-1 text-xs font-medium text-danger">{error('confirm')}</p>
        ) : null}
      </>
    ),
  },
]

export default function OnboardingForm() {
  return (
    <Wizard
      title="Set up Foundry"
      description="Four steps. You can change any of this later."
      steps={steps}
      initialValues={{ region: 'eu-west', role: 'member', confirm: 'false' }}
      submitLabel="Finish setup"
      successTitle="Workspace ready"
    />
  )
}

components/blocks/forms/onboarding.tsx

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox, Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { email, pattern, required } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'

/**
 * Onboarding wizard
 *
 * Four steps in the order that minimises abandonment: identity, then the
 * workspace, then the optional invite, then a review. The invite step is
 * skippable because forcing it at signup is the single most common reason
 * people abandon onboarding.
 */
const steps: WizardStep[] = [
  {
    id: 'account',
    label: 'Account',
    description: 'Name and email',
    fields: {
      name: [required('Full name')],
      email: [required('Email'), email()],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="name" label="Full 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="Work email" required error={error('email')}>
          {(field) => (
            <Input
              {...field}
              type="email"
              autoComplete="email"
              value={values.email ?? ''}
              onChange={(e) => setValue('email', e.target.value)}
            />
          )}
        </Field>
      </>
    ),
  },
  {
    id: 'workspace',
    label: 'Workspace',
    description: 'Name, slug and region',
    fields: {
      workspace: [required('Workspace name')],
      slug: [
        required('Slug'),
        pattern(/^[a-z0-9-]{3,32}$/, 'Use 3–32 lowercase letters, digits or hyphens.'),
      ],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field name="workspace" label="Workspace name" required error={error('workspace')}>
          {(field) => (
            <Input
              {...field}
              placeholder="Acme Platform"
              value={values.workspace ?? ''}
              onChange={(e) => setValue('workspace', e.target.value)}
            />
          )}
        </Field>
        <Field
          name="slug"
          label="Workspace URL"
          required
          error={error('slug')}
          hint={`foundry.example.com/${values.slug || 'your-slug'}`}
        >
          {(field) => (
            <Input
              {...field}
              className="font-mono"
              placeholder="acme-platform"
              value={values.slug ?? ''}
              onChange={(e) => setValue('slug', e.target.value)}
            />
          )}
        </Field>
        <Field name="region" label="Data region">
          {(field) => (
            <Select
              {...field}
              value={values.region ?? 'eu-west'}
              onChange={(e) => setValue('region', e.target.value)}
              options={[
                { value: 'eu-west', label: 'EU West (Ireland)' },
                { value: 'us-east', label: 'US East (Virginia)' },
                { value: 'ap-south', label: 'Asia Pacific (Singapore)' },
              ]}
            />
          )}
        </Field>
      </>
    ),
  },
  {
    id: 'team',
    label: 'Invite team',
    description: 'Optional',
    fields: {
      invites: [
        (value) => {
          if (!value.trim()) return null
          const addresses = value
            .split(/[,\n]/)
            .map((entry) => entry.trim())
            .filter(Boolean)
          const invalid = addresses.find(
            (address) => !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(address),
          )
          return invalid ? `“${invalid}” is not a valid email address.` : null
        },
      ],
    },
    render: ({ values, setValue, error }) => (
      <>
        <Field
          name="invites"
          label="Invite colleagues"
          showOptional
          error={error('invites')}
          hint="One address per line, or separated by commas. You can skip this and invite people later."
        >
          {(field) => (
            <textarea
              {...field}
              rows={4}
              className="w-full rounded-md border border-line bg-surface px-3 py-2 text-sm"
              value={values.invites ?? ''}
              onChange={(e) => setValue('invites', e.target.value)}
              placeholder={'tomas@acme.com\namara@acme.com'}
            />
          )}
        </Field>
        <Fieldset legend="Default role for invitees" name="role">
          <Radio
            id="role-member"
            name="role"
            value="member"
            label="Member"
            description="Can view and edit projects."
            checked={(values.role ?? 'member') === 'member'}
            onChange={() => setValue('role', 'member')}
          />
          <Radio
            id="role-admin"
            name="role"
            value="admin"
            label="Admin"
            description="Can also manage billing and members."
            checked={values.role === 'admin'}
            onChange={() => setValue('role', 'admin')}
          />
        </Fieldset>
      </>
    ),
  },
  {
    id: 'review',
    label: 'Review',
    description: 'Confirm and finish',
    fields: {
      confirm: [(value) => (value === 'true' ? null : 'Confirm the details to finish.')],
    },
    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: 'Workspace', value: values.workspace },
            { label: 'URL', value: values.slug ? `foundry.example.com/${values.slug}` : '—' },
            { label: 'Region', value: values.region ?? 'eu-west' },
            {
              label: 'Invitations',
              value: values.invites?.trim()
                ? `${values.invites.split(/[,\n]/).filter((entry) => entry.trim()).length} people`
                : 'None',
            },
          ].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="onboarding-confirm"
          name="confirm"
          label="These details are correct"
          checked={values.confirm === 'true'}
          onChange={(e) => setValue('confirm', String(e.target.checked))}
        />
        {error('confirm') ? (
          <p className="-mt-1 text-xs font-medium text-danger">{error('confirm')}</p>
        ) : null}
      </>
    ),
  },
]

export default function OnboardingForm() {
  return (
    <Wizard
      title="Set up Foundry"
      description="Four steps. You can change any of this later."
      steps={steps}
      initialValues={{ region: 'eu-west', role: 'member', confirm: 'false' }}
      submitLabel="Finish setup"
      successTitle="Workspace ready"
    />
  )
}

components/ui/stepper.tsx

import { Check } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Stepper
 *
 * Progress through a multi-step flow. Rendered as an ordered list so the count
 * and order are conveyed structurally, with `aria-current="step"` on the
 * active step and a visually hidden status word ("completed", "current") on
 * each — the connector line and tick alone are not information.
 */
export interface Step {
  id: string
  label: string
  description?: string
}

export interface StepperProps {
  steps: Step[]
  /** Zero-based index of the current step. */
  current: number
  orientation?: 'horizontal' | 'vertical'
  className?: string
  label?: string
}

export function Stepper({
  steps,
  current,
  orientation = 'horizontal',
  className,
  label = 'Progress',
}: StepperProps) {
  return (
    <nav aria-label={label} className={className}>
      <ol
        className={cn(
          orientation === 'horizontal'
            ? 'flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-0'
            : 'flex flex-col',
        )}
      >
        {steps.map((step, index) => {
          const complete = index < current
          const active = index === current
          const isLast = index === steps.length - 1

          return (
            <li
              key={step.id}
              aria-current={active ? 'step' : undefined}
              className={cn(
                'flex min-w-0 gap-3',
                orientation === 'horizontal' ? 'sm:flex-1 sm:flex-col sm:gap-2' : 'pb-6 last:pb-0',
              )}
            >
              <div
                className={cn(
                  'flex items-center gap-3',
                  orientation === 'vertical' && 'flex-col self-stretch',
                )}
              >
                <span
                  className={cn(
                    'flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold',
                    complete && 'border-accent bg-accent text-accent-ink',
                    active && 'border-accent bg-accent-soft text-accent-soft-ink',
                    !complete && !active && 'border-line bg-surface text-ink-subtle',
                  )}
                >
                  {complete ? <Check className="size-3.5" aria-hidden="true" /> : index + 1}
                </span>
                {!isLast ? (
                  <span
                    aria-hidden="true"
                    className={cn(
                      orientation === 'horizontal'
                        ? 'hidden h-px flex-1 sm:block'
                        : 'w-px flex-1 self-center',
                      complete ? 'bg-accent' : 'bg-line',
                    )}
                  />
                ) : null}
              </div>

              <div className={cn('min-w-0', orientation === 'horizontal' && 'sm:pr-4')}>
                <p
                  className={cn(
                    'text-sm leading-snug font-medium',
                    active ? 'text-ink-strong' : complete ? 'text-ink' : 'text-ink-muted',
                  )}
                >
                  {step.label}
                  <span className="sr-only">
                    {complete ? ' — completed' : active ? ' — current step' : ' — not started'}
                  </span>
                </p>
                {step.description ? (
                  <p className="mt-0.5 text-xs text-ink-muted">{step.description}</p>
                ) : null}
              </div>
            </li>
          )
        })}
      </ol>
    </nav>
  )
}

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 identity, workspace, optional invite, review. The invite step is skippable because forcing it at signup is the single most common reason people abandon onboarding.

  • Validation is per step; advancing never validates fields the user has not reached.
  • Going back preserves everything already entered.
  • The review step lists exactly what will be created, so Finish is never a leap of faith.

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.

  • Per-step validation
  • Skippable invite step
  • Review summary
  • Progress announcement

Accessibility

Focus on advance
Focus moves to the new step heading, so a screen-reader user knows the page changed.
Progress announcement
A polite live region announces “Step 2 of 4: Workspace”.
Stepper semantics
Progress is an ordered list with `aria-current` and state in words.

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