Skip to main content

Wizard Forms

Build multi-page wizard-style forms with built-in navigation and validation.

Overview

When a Forma specification includes a pages array, the form automatically becomes a wizard with:

  • Page-by-page navigation
  • Per-page validation
  • Progress tracking
  • Conditional page visibility

Using FormRenderer

The FormRenderer handles wizard navigation automatically:

RegistrationWizard.tsx
import { FormRenderer } from "@fogpipe/forma-react";

const wizardSpec: Forma = {
version: "1.0",
meta: { id: "wizard", title: "Registration Wizard" },
schema: {
/* ... */
},
fields: {
/* ... */
},
fieldOrder: ["firstName", "lastName", "email", "password"],
pages: [
{
id: "personal",
title: "Personal Info",
fields: ["firstName", "lastName"],
},
{
id: "account",
title: "Account Setup",
fields: ["email", "password"],
},
],
};

function RegistrationWizard() {
return (
<FormRenderer
spec={wizardSpec}
components={components}
onSubmit={(data) => console.log("Complete!", data)}
/>
);
}

Custom Wizard with useForma

For full control over wizard UI, use the useForma hook:

CustomWizard.tsx
import { useForma } from "@fogpipe/forma-react";

function CustomWizard({ spec }: { spec: Forma }) {
const forma = useForma({
spec,
onSubmit: (data) => console.log("Submitted:", data),
});

const { wizard } = forma;

if (!wizard) {
return <div>This form doesn't have pages</div>;
}

return (
<div>
{/* Progress indicator */}
<div className="progress">
{wizard.pages.map((page, index) => (
<div
key={page.id}
className={`step ${index === wizard.currentPageIndex ? "active" : ""}`}
>
{page.title}
</div>
))}
</div>

{/* Current page */}
<div className="page">
<h2>{wizard.currentPage?.title}</h2>
<p>{wizard.currentPage?.description}</p>

{/* Render fields for current page */}
{wizard.currentPage?.fields.map((fieldId) => {
if (!forma.visibility[fieldId]) return null;

const fieldDef = spec.fields[fieldId];
return (
<div key={fieldId}>
<label>{fieldDef?.label}</label>
<input
value={String(forma.data[fieldId] || "")}
onChange={(e) => forma.setFieldValue(fieldId, e.target.value)}
onBlur={() => forma.setFieldTouched(fieldId)}
/>
</div>
);
})}
</div>

{/* Navigation — use separate Next and Submit buttons */}
<div className="navigation">
<button
onClick={wizard.previousPage}
disabled={!wizard.hasPreviousPage}
>
Previous
</button>

{!wizard.isLastPage && (
<button onClick={wizard.handleNext} disabled={!wizard.canProceed}>
Next
</button>
)}

{wizard.isLastPage && (
<button onClick={() => forma.submitForm()} disabled={!forma.isValid}>
Submit
</button>
)}
</div>
</div>
);
}

Wizard Helpers

The wizard object from useForma provides:

Properties

PropertyTypeDescription
pagesPageState[]All pages (each has a visible boolean)
currentPagePageState | nullCurrent page definition
currentPageIndexnumberZero-based page index
hasPreviousPagebooleanCan navigate back
hasNextPagebooleanHas more pages
isLastPagebooleanOn last page
canProceedbooleanCurrent page is valid

Methods

MethodDescription
nextPage()Go to next visible page (no-op on last page)
previousPage()Go to previous page
handleNext()Safe "Next" button handler — advances page, never triggers submission
goToPage(index)Jump to specific page
touchCurrentPageFields()Mark all fields on the current page as touched
validateCurrentPage()Check if current page has validation errors; returns boolean

Page Validation

You can validate the current page before navigating:

const handleNext = () => {
// Touch fields and check validation before proceeding
wizard.touchCurrentPageFields();
if (wizard.validateCurrentPage()) {
wizard.nextPage();
}
};

// Or use canProceed which reflects current validation state
if (wizard.canProceed) {
wizard.nextPage();
}

Progress Indicator

Build a progress bar by computing progress from wizard state:

ProgressBar.tsx
function ProgressBar({ wizard }) {
const visiblePages = wizard.pages.filter((p) => p.visible);
const progress = ((wizard.currentPageIndex + 1) / visiblePages.length) * 100;

return (
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }} />
<span>{Math.round(progress)}% complete</span>
</div>
);
}

Step Indicator

Show steps with completion status:

StepIndicator.tsx
function StepIndicator({ wizard }) {
return (
<div className="steps">
{wizard.pages.map((page, index) => {
const isComplete = index < wizard.currentPageIndex;
const isCurrent = index === wizard.currentPageIndex;

return (
<div
key={page.id}
className={`
step
${isComplete ? "complete" : ""}
${isCurrent ? "current" : ""}
`}
>
<div className="step-number">{index + 1}</div>
<div className="step-title">{page.title}</div>
</div>
);
})}
</div>
);
}

Conditional Pages

Pages with visibleWhen are automatically shown/hidden:

const spec: Forma = {
// ...
pages: [
{
id: "type",
title: "Select Type",
fields: ["accountType"],
},
{
id: "business",
title: "Business Details",
visibleWhen: 'accountType = "business"',
fields: ["companyName", "taxId"],
},
{
id: "individual",
title: "Personal Details",
visibleWhen: 'accountType = "individual"',
fields: ["ssn", "dateOfBirth"],
},
{
id: "complete",
title: "Complete",
fields: ["agreeToTerms"],
},
],
};

The wizard automatically:

  • Skips hidden pages during navigation
  • Updates wizard.pages with visible flags for each page