Setup wizard
A vertical technical configuration flow that generates its summary command from the entered values.
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, Switch } from '@/components/ui/choice'
import { Combobox } from '@/components/ui/combobox'
import { Alert } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { pattern, required, url } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'
/**
* Project setup wizard
*
* A technical configuration flow rendered vertically, because each step has a
* different shape and a horizontal stepper would compress the labels. Every
* choice affects the summary command shown at the end, which is generated from
* the collected values rather than being a static string.
*/
const steps: WizardStep[] = [
{
id: 'project',
label: 'Project',
description: 'Name and repository',
fields: {
project: [required('Project name')],
repo: [required('Repository'), url()],
branch: [
required('Branch'),
pattern(/^[\w./-]{1,64}$/, 'Use letters, digits, dots, slashes or hyphens.'),
],
},
render: ({ values, setValue, error }) => (
<>
<Field name="project" label="Project name" required error={error('project')}>
{(field) => (
<Input
{...field}
placeholder="acme-platform"
value={values.project ?? ''}
onChange={(e) => setValue('project', e.target.value)}
/>
)}
</Field>
<Field name="repo" label="Repository" required error={error('repo')}>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="github.example/acme/platform"
value={values.repo ?? ''}
onChange={(e) => setValue('repo', e.target.value)}
/>
)}
</Field>
<Field name="branch" label="Production branch" required error={error('branch')}>
{(field) => (
<Input
{...field}
className="font-mono"
value={values.branch ?? ''}
onChange={(e) => setValue('branch', e.target.value)}
/>
)}
</Field>
</>
),
},
{
id: 'framework',
label: 'Framework',
description: 'Build and output',
fields: { framework: [required('Framework')] },
render: ({ values, setValue, error }) => (
<>
<Field
name="framework"
label="Framework"
required
error={error('framework')}
hint="The build command adapts to your choice."
>
{(field) => (
<Combobox
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
value={values.framework ?? 'next'}
onValueChange={(value) => setValue('framework', value)}
options={[
{ value: 'next', label: 'Next.js', description: 'App Router, server components' },
{ value: 'vite', label: 'Vite', description: 'Static SPA output' },
{ value: 'astro', label: 'Astro', description: 'Content-first, islands' },
{ value: 'remix', label: 'Remix', description: 'Nested routing, loaders' },
]}
/>
)}
</Field>
<Field
name="output"
label="Output directory"
hint="Leave as the framework default unless you have changed it."
>
{(field) => (
<Input
{...field}
className="font-mono"
placeholder=".next"
value={values.output ?? ''}
onChange={(e) => setValue('output', e.target.value)}
/>
)}
</Field>
<Fieldset legend="Node version" name="node">
<Radio
id="node-20"
name="node"
value="20"
label="Node 20 LTS"
checked={(values.node ?? '22') === '20'}
onChange={() => setValue('node', '20')}
/>
<Radio
id="node-22"
name="node"
value="22"
label="Node 22 LTS"
checked={(values.node ?? '22') === '22'}
onChange={() => setValue('node', '22')}
/>
<Radio
id="node-24"
name="node"
value="24"
label="Node 24"
checked={values.node === '24'}
onChange={() => setValue('node', '24')}
/>
</Fieldset>
</>
),
},
{
id: 'environments',
label: 'Environments',
description: 'Previews and protection',
fields: {},
render: ({ values, setValue }) => (
<>
<Switch
id="setup-previews"
name="previews"
align="trailing"
checked={(values.previews ?? 'true') === 'true'}
onChange={(e) => setValue('previews', String(e.target.checked))}
label="Preview environment per pull request"
description="Each PR gets its own URL, torn down on merge."
/>
<Switch
id="setup-protection"
name="protection"
align="trailing"
checked={values.protection === 'true'}
onChange={(e) => setValue('protection', String(e.target.checked))}
label="Require review before production deploys"
description="A second person must approve each production release."
/>
<Fieldset legend="Regions" name="regions" hint="Select every region to deploy to.">
{[
{ id: 'eu-west', label: 'EU West (Ireland)' },
{ id: 'us-east', label: 'US East (Virginia)' },
{ id: 'ap-south', label: 'Asia Pacific (Singapore)' },
].map((region) => {
const set = new Set((values.regions ?? 'eu-west').split(',').filter(Boolean))
return (
<Checkbox
key={region.id}
id={`region-${region.id}`}
name="regions"
value={region.id}
label={region.label}
checked={set.has(region.id)}
onChange={(e) => {
if (e.target.checked) set.add(region.id)
else set.delete(region.id)
setValue('regions', Array.from(set).join(','))
}}
/>
)
})}
</Fieldset>
</>
),
},
{
id: 'finish',
label: 'Finish',
description: 'Review the configuration',
fields: {},
render: ({ values }) => {
const framework = values.framework ?? 'next'
const buildCommand =
framework === 'vite'
? 'vite build'
: framework === 'astro'
? 'astro build'
: `${framework} build`
return (
<>
<Alert tone="info" title="Generated configuration">
This command is built from the values you entered, not hard-coded.
</Alert>
<pre className="thin-scrollbar overflow-x-auto rounded-lg border border-line bg-code-surface p-4 font-mono text-xs text-code-ink">
<code>{`foundry deploy \\
--project ${values.project || 'unnamed'} \\
--repo ${values.repo || 'not-set'} \\
--branch ${values.branch || 'main'} \\
--node ${values.node ?? '22'} \\
--build "${buildCommand}" \\
--regions ${values.regions ?? 'eu-west'}${(values.previews ?? 'true') === 'true' ? ' \\\n --previews' : ''}${values.protection === 'true' ? ' \\\n --require-review' : ''}`}</code>
</pre>
<div className="flex flex-wrap gap-2">
<Badge tone="accent">{framework}</Badge>
<Badge>Node {values.node ?? '22'}</Badge>
<Badge>{(values.regions ?? 'eu-west').split(',').length} regions</Badge>
{(values.previews ?? 'true') === 'true' ? (
<Badge tone="success">Previews on</Badge>
) : null}
{values.protection === 'true' ? <Badge tone="warning">Review required</Badge> : null}
</div>
</>
)
},
},
]
export default function SetupWizardForm() {
return (
<Wizard
title="Set up deployment"
description="Vertical stepper — each step has a different shape, so labels need the room."
steps={steps}
orientation="vertical"
initialValues={{
branch: 'main',
framework: 'next',
node: '22',
regions: 'eu-west',
previews: 'true',
protection: 'false',
}}
submitLabel="Create deployment"
successTitle="Deployment configured"
/>
)
}
components/blocks/forms/setup-wizard.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox, Radio, Switch } from '@/components/ui/choice'
import { Combobox } from '@/components/ui/combobox'
import { Alert } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { pattern, required, url } from '@/lib/validation'
import { Wizard, type WizardStep } from './_wizard'
/**
* Project setup wizard
*
* A technical configuration flow rendered vertically, because each step has a
* different shape and a horizontal stepper would compress the labels. Every
* choice affects the summary command shown at the end, which is generated from
* the collected values rather than being a static string.
*/
const steps: WizardStep[] = [
{
id: 'project',
label: 'Project',
description: 'Name and repository',
fields: {
project: [required('Project name')],
repo: [required('Repository'), url()],
branch: [
required('Branch'),
pattern(/^[\w./-]{1,64}$/, 'Use letters, digits, dots, slashes or hyphens.'),
],
},
render: ({ values, setValue, error }) => (
<>
<Field name="project" label="Project name" required error={error('project')}>
{(field) => (
<Input
{...field}
placeholder="acme-platform"
value={values.project ?? ''}
onChange={(e) => setValue('project', e.target.value)}
/>
)}
</Field>
<Field name="repo" label="Repository" required error={error('repo')}>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="github.example/acme/platform"
value={values.repo ?? ''}
onChange={(e) => setValue('repo', e.target.value)}
/>
)}
</Field>
<Field name="branch" label="Production branch" required error={error('branch')}>
{(field) => (
<Input
{...field}
className="font-mono"
value={values.branch ?? ''}
onChange={(e) => setValue('branch', e.target.value)}
/>
)}
</Field>
</>
),
},
{
id: 'framework',
label: 'Framework',
description: 'Build and output',
fields: { framework: [required('Framework')] },
render: ({ values, setValue, error }) => (
<>
<Field
name="framework"
label="Framework"
required
error={error('framework')}
hint="The build command adapts to your choice."
>
{(field) => (
<Combobox
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
value={values.framework ?? 'next'}
onValueChange={(value) => setValue('framework', value)}
options={[
{ value: 'next', label: 'Next.js', description: 'App Router, server components' },
{ value: 'vite', label: 'Vite', description: 'Static SPA output' },
{ value: 'astro', label: 'Astro', description: 'Content-first, islands' },
{ value: 'remix', label: 'Remix', description: 'Nested routing, loaders' },
]}
/>
)}
</Field>
<Field
name="output"
label="Output directory"
hint="Leave as the framework default unless you have changed it."
>
{(field) => (
<Input
{...field}
className="font-mono"
placeholder=".next"
value={values.output ?? ''}
onChange={(e) => setValue('output', e.target.value)}
/>
)}
</Field>
<Fieldset legend="Node version" name="node">
<Radio
id="node-20"
name="node"
value="20"
label="Node 20 LTS"
checked={(values.node ?? '22') === '20'}
onChange={() => setValue('node', '20')}
/>
<Radio
id="node-22"
name="node"
value="22"
label="Node 22 LTS"
checked={(values.node ?? '22') === '22'}
onChange={() => setValue('node', '22')}
/>
<Radio
id="node-24"
name="node"
value="24"
label="Node 24"
checked={values.node === '24'}
onChange={() => setValue('node', '24')}
/>
</Fieldset>
</>
),
},
{
id: 'environments',
label: 'Environments',
description: 'Previews and protection',
fields: {},
render: ({ values, setValue }) => (
<>
<Switch
id="setup-previews"
name="previews"
align="trailing"
checked={(values.previews ?? 'true') === 'true'}
onChange={(e) => setValue('previews', String(e.target.checked))}
label="Preview environment per pull request"
description="Each PR gets its own URL, torn down on merge."
/>
<Switch
id="setup-protection"
name="protection"
align="trailing"
checked={values.protection === 'true'}
onChange={(e) => setValue('protection', String(e.target.checked))}
label="Require review before production deploys"
description="A second person must approve each production release."
/>
<Fieldset legend="Regions" name="regions" hint="Select every region to deploy to.">
{[
{ id: 'eu-west', label: 'EU West (Ireland)' },
{ id: 'us-east', label: 'US East (Virginia)' },
{ id: 'ap-south', label: 'Asia Pacific (Singapore)' },
].map((region) => {
const set = new Set((values.regions ?? 'eu-west').split(',').filter(Boolean))
return (
<Checkbox
key={region.id}
id={`region-${region.id}`}
name="regions"
value={region.id}
label={region.label}
checked={set.has(region.id)}
onChange={(e) => {
if (e.target.checked) set.add(region.id)
else set.delete(region.id)
setValue('regions', Array.from(set).join(','))
}}
/>
)
})}
</Fieldset>
</>
),
},
{
id: 'finish',
label: 'Finish',
description: 'Review the configuration',
fields: {},
render: ({ values }) => {
const framework = values.framework ?? 'next'
const buildCommand =
framework === 'vite'
? 'vite build'
: framework === 'astro'
? 'astro build'
: `${framework} build`
return (
<>
<Alert tone="info" title="Generated configuration">
This command is built from the values you entered, not hard-coded.
</Alert>
<pre className="thin-scrollbar overflow-x-auto rounded-lg border border-line bg-code-surface p-4 font-mono text-xs text-code-ink">
<code>{`foundry deploy \\
--project ${values.project || 'unnamed'} \\
--repo ${values.repo || 'not-set'} \\
--branch ${values.branch || 'main'} \\
--node ${values.node ?? '22'} \\
--build "${buildCommand}" \\
--regions ${values.regions ?? 'eu-west'}${(values.previews ?? 'true') === 'true' ? ' \\\n --previews' : ''}${values.protection === 'true' ? ' \\\n --require-review' : ''}`}</code>
</pre>
<div className="flex flex-wrap gap-2">
<Badge tone="accent">{framework}</Badge>
<Badge>Node {values.node ?? '22'}</Badge>
<Badge>{(values.regions ?? 'eu-west').split(',').length} regions</Badge>
{(values.previews ?? 'true') === 'true' ? (
<Badge tone="success">Previews on</Badge>
) : null}
{values.protection === 'true' ? <Badge tone="warning">Review required</Badge> : null}
</div>
</>
)
},
},
]
export default function SetupWizardForm() {
return (
<Wizard
title="Set up deployment"
description="Vertical stepper — each step has a different shape, so labels need the room."
steps={steps}
orientation="vertical"
initialValues={{
branch: 'main',
framework: 'next',
node: '22',
regions: 'eu-west',
previews: 'true',
protection: 'false',
}}
submitLabel="Create deployment"
successTitle="Deployment configured"
/>
)
}
components/ui/combobox.tsx
'use client'
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { ChevronDown, Check, Search } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
import { controlSurface } from './input'
/**
* Combobox
*
* Implements the ARIA 1.2 combobox pattern with a filterable listbox popup:
* the text input owns `role="combobox"`, `aria-expanded` and
* `aria-activedescendant`, and the list is a real `role="listbox"` whose
* options carry `aria-selected`.
*
* Focus never leaves the input, which is what makes arrow-key browsing feel
* right and keeps the on-screen keyboard open on mobile.
*/
export interface ComboboxOption {
value: string
label: string
description?: string
disabled?: boolean
}
export interface ComboboxProps {
options: ComboboxOption[]
id?: string
name?: string
value?: string
defaultValue?: string
placeholder?: string
emptyMessage?: string
disabled?: boolean
invalid?: boolean
className?: string
'aria-describedby'?: string
onValueChange?: (value: string) => void
}
export function Combobox({
options,
id = 'combobox',
name,
value: controlledValue,
defaultValue = '',
placeholder = 'Select an option',
emptyMessage = 'No matches found.',
disabled = false,
invalid = false,
className,
onValueChange,
...aria
}: ComboboxProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const [uncontrolled, setUncontrolled] = useState(defaultValue)
const value = controlledValue ?? uncontrolled
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listId = `${id}-listbox`
const selected = options.find((option) => option.value === value)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter(
(option) =>
option.label.toLowerCase().includes(q) ||
option.description?.toLowerCase().includes(q) ||
option.value.toLowerCase().includes(q),
)
}, [options, query])
useDismiss([rootRef], open, () => {
setOpen(false)
setQuery('')
})
const select = (option: ComboboxOption) => {
if (option.disabled) return
if (controlledValue === undefined) setUncontrolled(option.value)
onValueChange?.(option.value)
setOpen(false)
setQuery('')
inputRef.current?.focus()
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!open) {
setOpen(true)
setActiveIndex(0)
return
}
const direction = event.key === 'ArrowDown' ? 1 : -1
setActiveIndex((current) => {
if (filtered.length === 0) return 0
return (current + direction + filtered.length) % filtered.length
})
} else if (event.key === 'Enter') {
if (!open) return
event.preventDefault()
const option = filtered[activeIndex]
if (option) select(option)
} else if (event.key === 'Home' && open) {
event.preventDefault()
setActiveIndex(0)
} else if (event.key === 'End' && open) {
event.preventDefault()
setActiveIndex(Math.max(0, filtered.length - 1))
} else if (event.key === 'Tab') {
setOpen(false)
}
}
const activeOption = filtered[activeIndex]
return (
<div ref={rootRef} className={cn('relative', className)}>
{name ? <input type="hidden" name={name} value={value} /> : null}
<div className="relative flex items-center">
<Search
className="pointer-events-none absolute left-3 size-4 text-ink-subtle"
aria-hidden="true"
/>
<input
ref={inputRef}
id={id}
role="combobox"
type="text"
autoComplete="off"
disabled={disabled}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
open && activeOption ? `${id}-option-${activeOption.value}` : undefined
}
aria-invalid={invalid || undefined}
aria-describedby={aria['aria-describedby']}
placeholder={placeholder}
value={open ? query : (selected?.label ?? '')}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
if (!open) setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
className={cn(
controlSurface,
'h-control cursor-default pr-9 pl-9 text-sm',
invalid && 'border-danger bg-danger-soft',
)}
/>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={disabled}
onClick={() => {
setOpen((o) => !o)
inputRef.current?.focus()
}}
className="absolute right-2 flex size-6 items-center justify-center rounded-sm text-ink-subtle"
>
<ChevronDown
className={cn('size-4 transition-transform duration-150', open && 'rotate-180')}
/>
</button>
</div>
{open ? (
<ul
id={listId}
role="listbox"
aria-label="Options"
className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-md border border-line bg-surface-raised p-1 shadow-md thin-scrollbar"
>
{filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-ink-muted">{emptyMessage}</li>
) : (
filtered.map((option, index) => (
<li
key={option.value}
id={`${id}-option-${option.value}`}
role="option"
aria-selected={option.value === value}
aria-disabled={option.disabled || undefined}
onMouseEnter={() => setActiveIndex(index)}
onPointerDown={(event) => {
event.preventDefault()
select(option)
}}
className={cn(
'flex cursor-pointer items-start gap-2 rounded-sm px-2.5 py-2 text-sm',
index === activeIndex && 'bg-surface-sunken',
option.disabled && 'cursor-not-allowed opacity-50',
)}
>
<Check
className={cn(
'mt-0.5 size-4 shrink-0 text-accent',
option.value === value ? 'opacity-100' : 'opacity-0',
)}
aria-hidden="true"
/>
<span className="min-w-0">
<span className="block truncate font-medium text-ink">{option.label}</span>
{option.description ? (
<span className="block truncate text-xs text-ink-muted">
{option.description}
</span>
) : null}
</span>
</li>
))
)}
</ul>
) : null}
</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
Rendered vertically because each step has a different shape and a horizontal stepper would compress the labels. The final command is built from the collected values, not a static string — which is the point.
- The build command changes with the framework, so the summary is genuinely derived.
- Region selection is a multi-checkbox group, and the count appears in the summary badges.
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.
- Vertical stepper
- Framework combobox
- Environment switches
- Generated command
Accessibility
- Vertical stepper
- Orientation is a prop; the semantics are identical in both directions.
- Generated output
- The command block is a real preformatted element, selectable and copyable.
- Combobox
- Framework selection uses the ARIA combobox pattern.
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 formsOnboarding wizard
Four steps ordered to minimise abandonment, with a skippable invite step and a review screen.
advancedFeatured4 variantsCombobox
A filterable single-select implementing the ARIA 1.2 combobox pattern, with active-descendant browsing that never moves focus out of the input.
advancedFeatured5 variantsStepper
Multi-step progress in horizontal and vertical orientations, with state conveyed in words as well as ticks.
starter3 variants