Onboarding wizard
Four steps ordered to minimise abandonment, with a skippable invite step and a review screen.
Live preview
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.
Related
All formsStepper
Multi-step progress in horizontal and vertical orientations, with state conveyed in words as well as ticks.
starter3 variantsCustomer onboarding
Workspace creation with a live URL preview and a searchable region picker.
starter3 variantsSetup wizard
A vertical technical configuration flow that generates its summary command from the entered values.
advanced4 variants