Multi-page Navigation
Configure free or gated page navigation and use the cross-page error summary.
Multi-page forms are modeled with first-class Pages in UI Schema. Navigation behavior also belongs in UI Schema, because it changes validation UX rather than the underlying JSON Schema data contract.
Navigation modes
Schemaform's default is free navigation:
uiSchema: {
navigation: "free",
pages: [],
}In free mode, users can move between Pages at will. Submit validates all visible fields and reveals a cross-page error summary when anything blocks the Submission Payload.
Gated navigation is available when a form should behave like a strict wizard:
uiSchema: {
navigation: "gated",
pages: [],
}In gated mode, Next blocks on the current Page's visible field errors, reveals those errors, and focuses the first invalid field. A Renderer prop can override the UI Schema setting when an application needs a global policy.
Error reveal timing
revealErrors controls when field errors first appear:
uiSchema: {
revealErrors: "onBlur", // default
pages: [],
}Use "onBlur" for immediate field-level feedback after a field is visited. Use "onSubmit" when the form should stay quiet until a submit attempt or a blocked gated Next action.
Core still computes validity as Working State changes in both modes. The setting changes reveal and blocking behavior, not whether validation is current.
Error summary
On a blocked submit, the ShadCN Renderer shows a cross-page error summary. Each entry includes the Page title, Control label, and validation message. Clicking an entry navigates to the relevant Page and focuses the field.
The summary is especially important in free navigation, because the user may submit from the final Page while an earlier Page still has invalid visible fields.
Live example
This example starts with an invalid email and missing required fields. Jump to Review, submit, then use the error summary to navigate back to the fields that need attention.
Live form
Source
import type { FormDefinition } from "@mackehansson/schemaform";import { SchemaForm } from "@mackehansson/schemaform-shadcn";const intakeForm = { formatVersion: 1, schema: { type: "object", required: ["name", "email", "plan", "agree"], properties: { name: { type: "string", title: "Name", minLength: 2 }, email: { type: "string", title: "Email", format: "email" }, plan: { type: "string", title: "Plan", enum: ["Starter", "Business", "Enterprise"], }, notes: { type: "string", title: "Notes" }, agree: { type: "string", title: "I agree to the terms", enum: ["Yes"], }, }, }, uiSchema: { navigation: "free", revealErrors: "onSubmit", pages: [ { type: "Page", id: "page-contact", title: "Contact", children: [ { type: "Control", scope: "#/properties/name" }, { type: "Control", scope: "#/properties/email" }, ], }, { type: "Page", id: "page-plan", title: "Plan", children: [ { type: "Control", scope: "#/properties/plan", widget: "select", }, { type: "Control", scope: "#/properties/notes", widget: "textarea", }, ], }, { type: "Page", id: "page-review", title: "Review", children: [ { type: "Control", scope: "#/properties/agree", widget: "radio", }, ], }, ], }, rules: [],} satisfies FormDefinition;export function App() { return ( <SchemaForm definition={intakeForm} defaultValues={{ email: "not-an-email" }} onSubmit={(data) => console.log(data)} /> );}