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:
defaultNextsets 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
- Next: Validates the current step’s fields, then navigates to the next step
- Back: Returns to the previous step via the history stack (no re-validation). Only rendered after the user has advanced at least once
- 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 and the i18n guide.
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 hook:
import { useFormState } from '@saastro/forms';
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:
- The current step’s
nextconditions are evaluated against the form values, in order — the first match wins - If no condition matches,
defaultNextis used - The current step id is pushed onto
stepHistoryand the resolved step becomes active - If nothing resolves, the step is the last one (
isLastistrue) andvalidateAndNext()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():
.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 — see Step Metadata below.
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
| 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:
// 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 withuseFormState— see 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.
import { StepsNavigation } from '@saastro/forms';
<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) — returnfalseand 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:
import { StepsProgress } from '@saastro/forms';
// 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.
import { StepsAccordion } from '@saastro/forms';
<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.
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 withsetDefaultMessagesbut not affected by the per-form i18n overlay.StepsProgressandStepsAccordionstill contain hardcoded Spanish strings (“Paso 1 de 3”, “Progreso”). For a fully localized step UI, build your own withuseFormStepsInfo.
Composing Step UI
A working wizard that pairs StepsProgress with a custom form driven by useFormState:
import { useFormState, StepsProgress } from '@saastro/forms';
import { FormProvider } from 'react-hook-form';
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.
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
| 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.
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();
// 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")