# Multi-Step Forms

URL: https://docs.forms.saastro.io/docs/multi-step
> Build multi-step wizards with conditional routing, step navigation, and progress tracking.

# Multi-Step Forms

Create multi-step wizards by defining multiple steps with `.addStep()`. Each step groups a subset of fields and can route to different next steps based on field values.

---

## Basic Multi-Step

```tsx

const config = FormBuilder.create('onboarding')
  .layout('manual')
  .columns(12)

  // Fields
  .addField('name', (f) => f.type('text').label('Name').required().columns({ default: 12, md: 6 }))
  .addField('email', (f) =>
    f.type('email').label('Email').required().email().columns({ default: 12, md: 6 }),
  )
  .addField('plan', (f) =>
    f
      .type('select')
      .label('Plan')
      .required()
      .options([
        { value: 'free', label: 'Free' },
        { value: 'pro', label: 'Pro' },
        { value: 'enterprise', label: 'Enterprise' },
      ]),
  )
  .addField('terms', (f) => f.type('checkbox').label('I accept the terms').mustBeTrue())

  // Steps
  .addStep('personal', ['name', 'email'], { defaultNext: 'plan-select' })
  .addStep('plan-select', ['plan'], { defaultNext: 'confirm' })
  .addStep('confirm', ['terms'])
  .initialStep('personal')

  // Buttons
  .buttons({
    align: 'between',
    submit: { type: 'submit', label: 'Complete' },
    next: { type: 'next', label: 'Continue' },
    back: { type: 'back', label: 'Back', variant: 'outline' },
  })

  .build();
```

Key points:

- **`defaultNext`** sets the next step when no conditions match
- The last step has no `defaultNext` — the submit button appears instead of next
- **`.initialStep()`** sets the starting step (defaults to the first step added)

---

## Step Navigation

Navigation is handled by the `next` and `back` buttons that `<Form />` renders below the current step's fields.

### How It Works

1. **Next**: Validates the current step's fields, then navigates to the next step
2. **Back**: Returns to the previous step via the history stack (no re-validation). Only rendered after the user has advanced at least once
3. **Submit**: Only shown on the last step (where no next step resolves)

If you don't set labels in `.buttons()`, the defaults are the built-in English strings — `Submit`, `Next →`, `← Back`. Override them per button (as in the example above), with a locale overlay, or globally with `setDefaultMessages` — see the [Buttons guide](/docs/buttons) and the [i18n guide](/docs/i18n).

### Step History

The form maintains a history stack. When the user goes back, they return to the exact step they came from — even with conditional routing.

```
Step 1 → Step 3 → Step 5    (history: [1, 3])
         ↑ Back goes to 3, then to 1
```

### Programmatic Navigation

`<Form />` drives navigation for you. When building a custom form UI instead, the same step engine is exposed through the public [`useFormState`](/docs/use-form-state) hook:

```tsx

const {
  currentStepId, // active step id
  stepHistory, // stack of visited step ids
  validateAndNext, // validate the current step → advance (or submit on the last step)
  prevStep, // pop the history stack; returns false when it's empty
  nextStep, // raw advance — skips validation and onStepChange callbacks
  isLast, // true when no next step resolves from the current values
} = useFormState({ config, fields: config.fields, steps: config.steps });
```

How a forward move resolves:

1. The current step's `next` conditions are evaluated against the form values, in order — the first match wins
2. If no condition matches, `defaultNext` is used
3. The current step id is pushed onto `stepHistory` and the resolved step becomes active
4. If nothing resolves, the step is the last one (`isLast` is `true`) and `validateAndNext()` submits instead of advancing

`prevStep()` pops the last entry from the history stack and returns to it. `nextStep(values)` advances **without validating** and without firing step-change callbacks — prefer `validateAndNext()`.

To react to transitions, register a callback with `.onStepChange()`:

```tsx
.onStepChange((stepId) => {
  // Note: receives the id of the step being LEFT, not the one being entered
  console.log('leaving step', stepId);
})
```

For step metadata (progress, completion, per-step errors), use [`useFormStepsInfo`](/docs/use-form-steps-info) — see [Step Metadata](#step-metadata) below.

---

## Conditional Routing

Route to different steps based on field values using the `next` option:

```tsx
.addStep('inquiry-type', ['type'], {
  defaultNext: 'general-info',
  next: [
    {
      conditions: [{ field: 'type', operator: 'equals', value: 'sales' }],
      operator: 'AND',
      target: 'sales-info',
    },
    {
      conditions: [{ field: 'type', operator: 'equals', value: 'support' }],
      operator: 'AND',
      target: 'support-info',
    },
  ],
})
```

Each entry in `next` is a `ConditionGroup` with a `target` step ID. Conditions are evaluated in order — the first match wins. If none match, `defaultNext` is used.

### Condition Operators

| Operator             | Description                  | Example                                                         |
| -------------------- | ---------------------------- | --------------------------------------------------------------- |
| `equals`             | Exact match                  | `{ field: 'plan', operator: 'equals', value: 'pro' }`           |
| `notEquals`          | Not equal                    | `{ field: 'plan', operator: 'notEquals', value: 'free' }`       |
| `contains`           | String/array contains        | `{ field: 'tags', operator: 'contains', value: 'urgent' }`      |
| `notContains`        | Does not contain             | `{ field: 'name', operator: 'notContains', value: 'test' }`     |
| `greaterThan`        | Number greater than          | `{ field: 'age', operator: 'greaterThan', value: 18 }`          |
| `lessThan`           | Number less than             | `{ field: 'budget', operator: 'lessThan', value: 1000 }`        |
| `greaterThanOrEqual` | Number greater than or equal | `{ field: 'score', operator: 'greaterThanOrEqual', value: 80 }` |
| `lessThanOrEqual`    | Number less than or equal    | `{ field: 'quantity', operator: 'lessThanOrEqual', value: 10 }` |
| `isTrue`             | Value is `true`              | `{ field: 'premium', operator: 'isTrue' }`                      |
| `isFalse`            | Value is `false`             | `{ field: 'newsletter', operator: 'isFalse' }`                  |
| `isEmpty`            | Empty, null, or undefined    | `{ field: 'phone', operator: 'isEmpty' }`                       |
| `isNotEmpty`         | Has a value                  | `{ field: 'company', operator: 'isNotEmpty' }`                  |

### AND / OR Grouping

Combine multiple conditions:

```tsx
// AND: all conditions must be true
{
  conditions: [
    { field: 'plan', operator: 'equals', value: 'enterprise' },
    { field: 'employees', operator: 'greaterThan', value: 100 },
  ],
  operator: 'AND',
  target: 'enterprise-setup',
}

// OR: any condition can be true
{
  conditions: [
    { field: 'country', operator: 'equals', value: 'US' },
    { field: 'country', operator: 'equals', value: 'CA' },
  ],
  operator: 'OR',
  target: 'north-america-setup',
}
```

---

## Step UI Components

The package exports three standalone components for step progress and navigation: `StepsNavigation`, `StepsProgress`, and `StepsAccordion`.

Two things to know before using them:

- **`<Form />` does not render them.** It only renders the current step's fields and the navigation buttons — there are no automatic step tabs or progress bars. You compose these components yourself.
- They read validation state through react-hook-form context, so they must be rendered **inside a `FormProvider`**. Since `<Form />` doesn't accept children, in practice that means driving the form yourself with [`useFormState`](/docs/use-form-state) — see [Composing Step UI](#composing-step-ui) below.

All three take the same core props: `steps`, `currentStepId`, `stepHistory`, and `fields`.

### StepsNavigation

Clickable step buttons with numbered circles, checkmark/error indicators for completed/invalid steps, and optional labels.

```tsx

<StepsNavigation
  steps={config.steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={config.fields}
  onStepClick={(stepId) => {
    // Fires only for steps the user has already visited.
    // Back navigation is one step at a time:
    if (stepId === stepHistory[stepHistory.length - 1]) prevStep();
  }}
  onBeforeNavigate={async () => {
    // Optional gate: return false to veto the jump — e.g. validate the
    // current step before allowing navigation to a visited step.
    return await validateStep();
  }}
  orientation="horizontal" // 'horizontal' (default) | 'vertical'
  showLabels={true} // Show "Step N" labels (default: true)
  showFieldCount={false} // Show field count badges (default: false)
  allowNavigation={true} // Allow clicking visited steps (default: true)
/>;
```

`onStepClick` only fires for visited steps. The navigation API is linear — `prevStep()` goes back one step at a time; there is no built-in jump-to-arbitrary-step. Set `allowNavigation={false}` to render it as a passive indicator.

> **Validation on jumps:** clicking a visited step does NOT validate the steps in between. If your flow requires every prior step to be valid, gate the jump with `onBeforeNavigate` (sync or async) — return `false` and the navigation is cancelled.

**When to use:** Multi-step forms where users benefit from an overview of all steps.

### StepsProgress

Lightweight progress indicator with three visual variants:

```tsx

// Progress bar (default)
<StepsProgress
  steps={config.steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={config.fields}
  variant="bar"             // 'bar' (default) | 'dots' | 'steps'
  showLabels={false}        // Show "Paso X de Y" text (default: false)
  showPercentage={true}     // Show "60%" text (default: true)
/>

// Dots — minimal indicator
<StepsProgress variant="dots" {...stepProps} />

// Steps — numbered step indicators
<StepsProgress variant="steps" showLabels={true} {...stepProps} />
```

**When to use:** When you want something lighter than full navigation tabs. `bar` for simple flows, `dots` for minimal UI, `steps` for numbered progress.

### StepsAccordion

Accordion layout where each step is a collapsible section. Expanding a step shows its field list and, for visited steps, its completion status.

```tsx

<StepsAccordion
  steps={config.steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={config.fields}
  onStepSelect={(stepId) => {
    // Fires only for visited steps — wire it to your own navigation logic
  }}
  allowNavigation={true} // Allow clicking to expand/navigate (default: true)
  showFieldCount={true} // Show field count badges (default: true)
  showErrors={true} // Show error indicators (default: true)
  showProgress={true} // Show completion checkmarks (default: true)
/>;
```

Unlike the other two, `StepsAccordion` renders through injected `Accordion`, `AccordionItem`, `AccordionTrigger`, and `AccordionContent` components, so it also needs a component provider above it (`FormComponentsProvider` or `ComponentProvider`) — `useComponents()` throws otherwise. See the [Components guide](/docs/components).

**When to use:** Long forms where users benefit from an at-a-glance overview of each step's progress. Works well for applications, surveys, and checkout flows.

> **Note on language:** `StepsNavigation`'s built-in text ("Step 1", "Current", the field/error count badges) comes from the package's default messages — English out of the box, switchable globally with [`setDefaultMessages`](/docs/i18n) but **not** affected by the per-form [i18n overlay](/docs/i18n). `StepsProgress` and `StepsAccordion` still contain hardcoded Spanish strings ("Paso 1 de 3", "Progreso"). For a fully localized step UI, build your own with [`useFormStepsInfo`](/docs/use-form-steps-info).

### Composing Step UI

A working wizard that pairs `StepsProgress` with a custom form driven by `useFormState`:

```tsx

function WizardWithProgress({ config }) {
  const {
    config: resolved, // render from the returned config (plugin + i18n transforms applied)
    methods,
    currentStepId,
    stepHistory,
    validateAndNext,
    prevStep,
    isLast,
    loading,
    submitted,
    getSuccessMessage,
  } = useFormState({ config, fields: config.fields, steps: config.steps });

  if (submitted) return <p>{getSuccessMessage()}</p>;

  return (
    <FormProvider {...methods}>
      <StepsProgress
        steps={resolved.steps}
        currentStepId={currentStepId}
        stepHistory={stepHistory}
        fields={resolved.fields}
        variant="bar"
      />

      <form
        onSubmit={(e) => {
          e.preventDefault();
          void validateAndNext(); // validates only the current step; submits on the last one
        }}
      >
        {resolved.steps[currentStepId].fields.map((name) => (
          <input key={name} {...methods.register(name)} placeholder={name} />
        ))}

        {stepHistory.length > 0 && (
          <button type="button" onClick={() => prevStep()}>
            Back
          </button>
        )}
        <button type="submit" disabled={loading}>
          {isLast ? 'Submit' : 'Next'}
        </button>
      </form>
    </FormProvider>
  );
}
```

Call `validateAndNext()` directly rather than wrapping it in `methods.handleSubmit`: the form's resolver validates **every** step's fields at once, so empty required fields in later steps would keep an intermediate step from ever advancing. `validateAndNext()` validates only the current step's fields and runs the full submit pipeline on the last one.

---

## Step Metadata

The `useFormStepsInfo` hook provides rich metadata for building custom step UI. Like the step components above, it must be called below react-hook-form's `FormProvider` (it reads errors and values via `useFormContext`) — inside a custom `useFormState` form, that's the provider you already added.

```tsx

function CustomStepUI({ steps, currentStepId, stepHistory, fields }) {
  const info = useFormStepsInfo({ steps, currentStepId, stepHistory, fields });

  return (
    <div>
      <p>
        Step {info.steps.findIndex((s) => s.isCurrent) + 1} of {info.totalSteps}
      </p>
      <p>Progress: {info.progress}%</p>
      {info.steps.map((step) => (
        <span key={step.id} className={step.status}>
          {step.id} ({step.status})
        </span>
      ))}
    </div>
  );
}
```

### FormStepsInfo Return Value

| Property            | Type                                    | Description                         |
| ------------------- | --------------------------------------- | ----------------------------------- |
| `steps`             | `StepInfo[]`                            | Array of step metadata              |
| `currentStepId`     | `string`                                | Current step ID                     |
| `totalSteps`        | `number`                                | Total number of steps               |
| `completedSteps`    | `number`                                | Number of completed steps           |
| `progress`          | `number`                                | Completion percentage (0-100)       |
| `stepHistory`       | `string[]`                              | Stack of visited step IDs           |
| `canGoNext`         | `boolean`                               | Whether next navigation is possible |
| `canGoPrev`         | `boolean`                               | Whether back navigation is possible |
| `getStepInfo(id)`   | `(id: string) => StepInfo \| undefined` | Get info for a specific step        |
| `getStepStatus(id)` | `(id: string) => StepStatus`            | Get status for a specific step      |

### StepInfo

| Property      | Type         | Description                                           |
| ------------- | ------------ | ----------------------------------------------------- |
| `id`          | `string`     | Step ID                                               |
| `fields`      | `string[]`   | Field names in this step                              |
| `fieldCount`  | `number`     | Number of fields                                      |
| `status`      | `StepStatus` | `'pending'`, `'current'`, `'completed'`, or `'error'` |
| `hasErrors`   | `boolean`    | Whether any fields have validation errors             |
| `errorCount`  | `number`     | Number of fields with errors                          |
| `isVisited`   | `boolean`    | Whether step has been visited                         |
| `isCurrent`   | `boolean`    | Whether this is the active step                       |
| `isCompleted` | `boolean`    | Whether all fields are valid and filled               |
| `canNavigate` | `boolean`    | Whether the user can navigate to this step            |

---

## Per-Step Validation

Fields are validated before advancing to the next step. Only the fields in the current step are checked — fields in other steps are not validated until their step is active.

If validation fails, the user stays on the current step and error messages appear on the invalid fields. A toast notification also fires — the default message is `'Please fix the errors before continuing'`, overridable globally via [`setDefaultMessages`](/docs/i18n).

---

## Complete Example

A multi-step onboarding wizard with conditional routing:

```tsx

const config = FormBuilder.create('onboarding')
  .layout('manual')
  .columns(12)

  // Step 1: Account Type
  .addField('accountType', (f) =>
    f
      .type('button-radio')
      .label('Account Type')
      .required()
      .options([
        { value: 'personal', label: 'Personal' },
        { value: 'business', label: 'Business' },
      ])
      .columns({ default: 12 }),
  )

  // Step 2a: Personal Info
  .addField('fullName', (f) =>
    f.type('text').label('Full Name').required().columns({ default: 12 }),
  )
  .addField('birthDate', (f) =>
    f.type('date').label('Date of Birth').required().columns({ default: 12, md: 6 }),
  )

  // Step 2b: Business Info
  .addField('companyName', (f) =>
    f.type('text').label('Company Name').required().columns({ default: 12, md: 6 }),
  )
  .addField('employees', (f) =>
    f
      .type('select')
      .label('Company Size')
      .required()
      .options([
        { value: '1-10', label: '1-10' },
        { value: '11-50', label: '11-50' },
        { value: '51-200', label: '51-200' },
        { value: '200+', label: '200+' },
      ])
      .columns({ default: 12, md: 6 }),
  )

  // Step 3: Confirmation
  .addField('newsletter', (f) => f.type('switch').label('Subscribe to newsletter').optional())
  .addField('terms', (f) =>
    f
      .type('checkbox')
      .label('I accept the terms and conditions')
      .mustBeTrue('You must accept the terms'),
  )

  // Define steps with conditional routing
  .addStep('account-type', ['accountType'], {
    defaultNext: 'personal-info',
    next: [
      {
        conditions: [{ field: 'accountType', operator: 'equals', value: 'business' }],
        operator: 'AND',
        target: 'business-info',
      },
    ],
  })
  .addStep('personal-info', ['fullName', 'birthDate'], { defaultNext: 'confirm' })
  .addStep('business-info', ['companyName', 'employees'], { defaultNext: 'confirm' })
  .addStep('confirm', ['newsletter', 'terms'])
  .initialStep('account-type')

  .buttons({
    align: 'between',
    submit: { type: 'submit', label: 'Create Account' },
    next: { type: 'next', label: 'Continue' },
    back: { type: 'back', label: 'Back', variant: 'outline' },
  })

  .onSuccess(async (values) => {
    await fetch('/api/onboarding', {
      method: 'POST',
      body: JSON.stringify(values),
    });
  })

  .build();

// Render it (see the Components guide for the `components` prop)
<Form config={config} components={uiComponents} />;

// The flow:
// account-type → personal-info → confirm     (if "personal")
// account-type → business-info → confirm     (if "business")
```
