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:

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

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
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.pages with visible flags 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>
);
}