Layout
Shape a form with Pages, Groups, Columns, Controls, and nested object scopes.
The UI Schema is Schemaform's presentation layer: a layout tree of Pages, layout nodes, and Controls. It decides where fields appear, while JSON Schema still owns data shape and validation.
Pages
Every UI Schema root has pages: [...]. A one-page form uses a one-element array; a multi-page form adds more Page nodes. A Page is purely presentational. One JSON Schema spans all pages, and the Submission Payload is produced from the whole visible form.
uiSchema: {
pages: [
{ type: "Page", title: "Profile", children: [] },
{ type: "Page", title: "Address", children: [] },
],
}Groups and Columns
Use Group nodes for titled sections and Columns nodes for equal-width side-by-side layout. Both are layout nodes: they can contain Controls or other layout nodes, and they can be targeted by Rules through an id.
{
type: "Group",
id: "name-group",
title: "Name",
children: [
{
type: "Columns",
id: "name-columns",
children: [
{ type: "Control", scope: "#/properties/firstName" },
{ type: "Control", scope: "#/properties/lastName" },
],
},
],
}Nested object mapping
A Control points to one JSON Schema property with a JSON Pointer scope. Nested objects keep walking through properties:
{ type: "Control", scope: "#/properties/address/properties/street" }That Control resolves to the Working State path address.street and the validation pointer /address/street. The object itself is still modeled in JSON Schema; UI Schema only chooses where its child Controls render.
Live example
This example uses two Pages, Groups for sections, Columns for compact rows, and nested object Controls for the address fields.
Live form
Source
import type { FormDefinition } from "@mackehansson/schemaform";import { SchemaForm } from "@mackehansson/schemaform-shadcn";const onboardingForm = { formatVersion: 1, schema: { type: "object", required: ["firstName", "lastName", "address"], properties: { firstName: { type: "string", title: "First name", minLength: 1 }, lastName: { type: "string", title: "Last name", minLength: 1 }, email: { type: "string", title: "Email", format: "email" }, address: { type: "object", title: "Address", required: ["street", "city"], properties: { street: { type: "string", title: "Street" }, city: { type: "string", title: "City" }, postalCode: { type: "string", title: "Postal code" }, }, }, notes: { type: "string", title: "Notes" }, }, }, uiSchema: { pages: [ { type: "Page", id: "page-profile", title: "Profile", children: [ { type: "Group", id: "name-group", title: "Name", children: [ { type: "Columns", id: "name-columns", children: [ { type: "Control", scope: "#/properties/firstName" }, { type: "Control", scope: "#/properties/lastName" }, ], }, { type: "Control", scope: "#/properties/email" }, ], }, ], }, { type: "Page", id: "page-address", title: "Address", children: [ { type: "Group", id: "address-group", title: "Mailing address", children: [ { type: "Control", scope: "#/properties/address/properties/street" }, { type: "Columns", id: "address-columns", children: [ { type: "Control", scope: "#/properties/address/properties/city" }, { type: "Control", scope: "#/properties/address/properties/postalCode" }, ], }, ], }, { type: "Control", scope: "#/properties/notes", widget: "textarea", }, ], }, ], }, rules: [],} satisfies FormDefinition;export function App() { return ( <SchemaForm definition={onboardingForm} onSubmit={(data) => console.log(data)} /> );}