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:
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:
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 */}
<div className="navigation">
<button
onClick={wizard.previousPage}
disabled={!wizard.hasPreviousPage}
>
Previous
</button>
{wizard.isLastPage ? (
<button onClick={() => forma.submitForm()} disabled={!forma.isValid}>
Submit
</button>
) : (
<button onClick={wizard.nextPage} disabled={!wizard.canProceed}>
Next
</button>
)}
</div>
</div>
);
}
Wizard Helpers
The wizard object from useForma provides:
Properties
| Property | Type | Description |
|---|---|---|
pages | PageState[] | All pages (each has a visible boolean) |
currentPage | PageState | null | Current page definition |
currentPageIndex | number | Zero-based page index |
hasPreviousPage | boolean | Can navigate back |
hasNextPage | boolean | Has more pages |
isLastPage | boolean | On last page |
canProceed | boolean | Current page is valid |
Methods
| Method | Description |
|---|---|
nextPage() | Go to next visible page |
previousPage() | Go to previous page |
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:
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:
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.pageswithvisibleflags for each page
Complete Wizard Example
import { useForma } from "@fogpipe/forma-react";
import type { Forma } from "@fogpipe/forma-core";
function WizardForm({
spec,
onComplete,
}: {
spec: Forma;
onComplete: (data: any) => void;
}) {
const forma = useForma({
spec,
onSubmit: onComplete,
});
const { wizard } = forma;
if (!wizard) return null;
return (
<div className="wizard">
{/* Header with progress */}
<header>
<h1>{spec.meta.title}</h1>
{(() => {
const visiblePages = wizard.pages.filter((p) => p.visible);
const progress =
((wizard.currentPageIndex + 1) / visiblePages.length) * 100;
return (
<div className="progress-track">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
);
})()}
<p>
Step {wizard.currentPageIndex + 1} of {wizard.pages.length}
</p>
</header>
{/* Page content */}
<main>
<h2>{wizard.currentPage?.title}</h2>
{wizard.currentPage?.description && (
<p className="description">{wizard.currentPage.description}</p>
)}
<div className="fields">
{wizard.currentPage?.fields.map((fieldId) => {
if (!forma.visibility[fieldId]) return null;
const fieldDef = spec.fields[fieldId];
return (
<div key={fieldId} className="field">
<label>
{fieldDef?.label}
{forma.required[fieldId] && (
<span className="required">*</span>
)}
</label>
<input
value={String(forma.data[fieldId] || "")}
onChange={(e) => forma.setFieldValue(fieldId, e.target.value)}
onBlur={() => forma.setFieldTouched(fieldId)}
disabled={!forma.enabled[fieldId]}
/>
{forma.errors
.filter((e) => e.field === fieldId)
.map((error, i) => (
<p key={i} className="error">
{error.message}
</p>
))}
</div>
);
})}
</div>
</main>
{/* Navigation footer */}
<footer>
<button
type="button"
onClick={wizard.previousPage}
disabled={!wizard.hasPreviousPage}
>
Back
</button>
{wizard.isLastPage ? (
<button
type="button"
onClick={() => forma.submitForm()}
disabled={!wizard.canProceed || forma.isSubmitting}
>
{forma.isSubmitting ? "Submitting..." : "Complete"}
</button>
) : (
<button
type="button"
onClick={wizard.nextPage}
disabled={!wizard.canProceed}
>
Continue
</button>
)}
</footer>
</div>
);
}