S

Multi-Step Forms

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

import { FormBuilder } from '@saastro/forms';

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 automatically by the next and back buttons.

How It Works

  1. Next: Validates current step fields, then navigates to the next step
  2. Back: Returns to the previous step via the history stack (no re-validation)
  3. Submit: Only shown on the last step (where there’s no defaultNext)

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

Conditional Routing

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

.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

OperatorDescriptionExample
equalsExact match{ field: 'plan', operator: 'equals', value: 'pro' }
notEqualsNot equal{ field: 'plan', operator: 'notEquals', value: 'free' }
containsString/array contains{ field: 'tags', operator: 'contains', value: 'urgent' }
notContainsDoes not contain{ field: 'name', operator: 'notContains', value: 'test' }
greaterThanNumber greater than{ field: 'age', operator: 'greaterThan', value: 18 }
lessThanNumber less than{ field: 'budget', operator: 'lessThan', value: 1000 }
greaterThanOrEqualNumber greater than or equal{ field: 'score', operator: 'greaterThanOrEqual', value: 80 }
lessThanOrEqualNumber less than or equal{ field: 'quantity', operator: 'lessThanOrEqual', value: 10 }
isTrueValue is true{ field: 'premium', operator: 'isTrue' }
isFalseValue is false{ field: 'newsletter', operator: 'isFalse' }
isEmptyEmpty, null, or undefined{ field: 'phone', operator: 'isEmpty' }
isNotEmptyHas a value{ field: 'company', operator: 'isNotEmpty' }

AND / OR Grouping

Combine multiple conditions:

// 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

Three built-in components for step progress and navigation. They render automatically inside <Form /> when your form has multiple steps, but you can also use them standalone.

StepsNavigation

Tab-style navigation with clickable step buttons. Shows numbered circles for each step with checkmark/error icons for completed/invalid steps.

import { StepsNavigation } from '@saastro/forms';

<StepsNavigation
  steps={steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={fields}
  onStepClick={(stepId) => goToStep(stepId)}
  orientation="horizontal" // 'horizontal' (default) | 'vertical'
  showLabels={true} // Show step labels (default: true)
  showFieldCount={false} // Show field count badges (default: false)
  allowNavigation={true} // Allow clicking to navigate (default: true)
/>;

When to use: Most multi-step forms. Gives users a clear overview of all steps and lets them jump back to completed steps.

StepsProgress

Lightweight progress indicator with three visual variants:

import { StepsProgress } from '@saastro/forms';

// Progress bar (default)
<StepsProgress
  steps={steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={fields}
  variant="bar"             // 'bar' (default) | 'dots' | 'steps'
  showLabels={false}        // Show step labels (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. Users can expand any visited step to review their answers.

import { StepsAccordion } from '@saastro/forms';

<StepsAccordion
  steps={steps}
  currentStepId={currentStepId}
  stepHistory={stepHistory}
  fields={fields}
  onStepSelect={(stepId) => goToStep(stepId)}
  allowNavigation={true} // Allow clicking to expand/navigate (default: true)
  showFieldCount={true} // Show "3 fields" badges (default: true)
  showErrors={true} // Show error indicators (default: true)
  showProgress={true} // Show completion checkmarks (default: true)
/>;

When to use: Long forms where users need to review previous answers. Works well for applications, surveys, and checkout flows.

Automatic Rendering

You don’t need to use these components directly. The <Form /> component renders StepsNavigation automatically when the config has multiple steps:

// This form will show step navigation tabs automatically
<Form config={multiStepConfig} components={uiComponents} />

Step Metadata

The useFormStepsInfo hook provides rich metadata for building custom step UI:

import { useFormStepsInfo } from '@saastro/forms';

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

PropertyTypeDescription
stepsStepInfo[]Array of step metadata
currentStepIdstringCurrent step ID
totalStepsnumberTotal number of steps
completedStepsnumberNumber of completed steps
progressnumberCompletion percentage (0-100)
stepHistorystring[]Stack of visited step IDs
canGoNextbooleanWhether next navigation is possible
canGoPrevbooleanWhether back navigation is possible
getStepInfo(id)(id: string) => StepInfoGet info for a specific step
getStepStatus(id)(id: string) => StepStatusGet status for a specific step

StepInfo

PropertyTypeDescription
idstringStep ID
fieldsstring[]Field names in this step
fieldCountnumberNumber of fields
statusStepStatus'pending', 'current', 'completed', or 'error'
hasErrorsbooleanWhether any fields have validation errors
errorCountnumberNumber of fields with errors
isVisitedbooleanWhether step has been visited
isCurrentbooleanWhether this is the active step
isCompletedbooleanWhether all fields are valid and filled
canNavigatebooleanWhether 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.


Complete Example

A multi-step onboarding wizard with conditional routing:

import { FormBuilder, Form } from '@saastro/forms';

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();

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