Skip to content

Setup wizard

A vertical technical configuration flow that generates its summary command from the entered values.

Onboardingadvancedwizardconfigurationdevopsverticalgenerated

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

All forms