Steps Form
A multi-step Formily wizard with Pro-shaped Next, Back, and whole-flow Submit on a single form.
Plus Registry
Steps Form is a Plus-registry component. See Installation — Plus Registry.
Steps Form is a page or embedded multi-step form shell: Stepper progress chrome, one Formily form, Next that validates only the current step, Back that keeps values, and a final Submit that runs onFinish for the whole flow — mirroring Ant Design Pro's StepsForm DX with Formily's single-VM model underneath.
One Formily Form
Use a single Formily form for the wizard. Do not create one form per step or merge bags (formMapRef). Pass an optional form / initialValues on StepsForm only.
Hidden Steps Keep Values
Non-current panes use Formily display: "hidden" so fields stay in the form VM and survive Back / step jumps. Next never validates hidden steps.
When To Use
- Onboarding, setup, or invite flows that need a clear step sequence with gated progression on a page or embedded surface.
- When a single scroll Form Page with sections would bury required gates behind a long page.
- When Modal Form is too small (multi-screen) but you still want one submit at the end — and the flow is long enough to stay on the page.
- Use Modal Steps Form for short, linear overlay wizards (roughly ≤ 4 steps) from a list Create / Edit.
- Use Form Page instead for multi-section create/edit on one page (drafts, unsaved guards, line items).
- Use Modal Form instead for short create/edit (roughly ≤ 8 fields) in an overlay.
Features
| Area | Behavior |
|---|---|
| Progress | Open Stepper (horizontal); click jumps only to indices ≤ maxReached; Next/Submit validation failure marks the current step error |
| VM | One Formily form; each StepsForm.Step is a pane (VoidField), not a second form |
| Next | Validates the current step only; advances on success |
| Back | Decrements step index; keeps values; no validate |
| Step click | Jump only to indices ≤ maxReached (no skipping ahead) |
| Submit | Last step: validate all steps → onFinish(values); issues / throw stay on last step |
| Submitter | Default Back + Next / Submit via FormActions; submitter={false} or render escape |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/steps-form.jsonnpx shadcn@latest add https://ui.isaacfei.com/api/plus/r/steps-form.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/steps-form.jsonbunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/steps-form.jsonInstalls under the @f-ui-plus/steps-form namespace. Depends on the @f-ui-plus/formily engine, the shadcn button primitive, and Open Stepper (https://ui.isaacfei.com/r/stepper.json via registryDependencies).
Usage
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
import { useMemo } from "react";
const form = useMemo(() => createForm<Values>(), []);
<StepsForm form={form} onFinish={save}>
<StepsForm.Step name="basics" title="Basics">
<FormField name="email" label="Email" required kind="email" />
</StepsForm.Step>
<StepsForm.Step name="access" title="Access">
<FormField name="role" label="Role" required kind="select" /* … */ />
</StepsForm.Step>
</StepsForm>;Pass StepsForm.Step as direct children of StepsForm (sibling elements).
Examples
Workspace Setup
Three steps — Basics, Region, Notify — then Submit. Open the console to see the full onFinish payload; Back keeps earlier values.
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
interface WorkspaceSetupValues {
name: string;
slug: string;
region: string;
plan: string;
notifyEmail: string;
}
const REGION_OPTIONS = [
{ label: "US East", value: "us-east" },
{ label: "EU West", value: "eu-west" },
{ label: "Asia Pacific", value: "ap-southeast" },
];
const PLAN_OPTIONS = [
{ label: "Starter", value: "starter" },
{ label: "Team", value: "team" },
{ label: "Business", value: "business" },
];
/**
* Three-step happy path: Basics → Region → Notify, then Submit.
* One Formily form for the whole wizard — values stay when you go Back.
*/
export function StepsFormDemo() {
const [lastSubmitted, setLastSubmitted] =
useState<WorkspaceSetupValues | null>(null);
return (
<div className="w-full max-w-lg space-y-4">
<StepsForm<WorkspaceSetupValues>
initialValues={{
name: "",
slug: "",
region: "us-east",
plan: "team",
notifyEmail: "",
}}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 400));
setLastSubmitted({ ...values });
toast.success(`Created workspace "${values.name}"`);
console.log("StepsForm onFinish", values);
}}
>
<StepsForm.Step name="basics" title="Basics">
<FormField
kind="text"
name="name"
label="Workspace name"
required
componentProps={{ placeholder: "Acme Ops" }}
/>
<FormField
kind="text"
name="slug"
label="URL slug"
required
componentProps={{ placeholder: "acme-ops" }}
/>
</StepsForm.Step>
<StepsForm.Step name="region" title="Region">
<FormField
kind="select"
name="region"
label="Primary region"
required
componentProps={{ options: REGION_OPTIONS }}
/>
<FormField
kind="select"
name="plan"
label="Plan"
required
componentProps={{ options: PLAN_OPTIONS }}
/>
</StepsForm.Step>
<StepsForm.Step name="notify" title="Notify">
<FormField
kind="email"
name="notifyEmail"
label="Alerts email"
required
componentProps={{ placeholder: "ops@company.com" }}
/>
</StepsForm.Step>
</StepsForm>
{lastSubmitted ? (
<p className="text-muted-foreground text-xs">
Last submit: {lastSubmitted.name} · {lastSubmitted.slug} ·{" "}
{lastSubmitted.region} · {lastSubmitted.plan} ·{" "}
{lastSubmitted.notifyEmail}
</p>
) : null}
</div>
);
}Blocked Next
Leave Work email empty and click Next. Errors stay on Basics. Fill the field, advance, then Back — the email is still there.
"use client";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
interface InviteWizardValues {
email: string;
role: string;
message: string;
}
/**
* Leave the first step empty and click Next — required errors stay on Basics.
* Fill email, advance, then Back: the email is still there.
*/
export function StepsFormValidationDemo() {
return (
<div className="w-full max-w-lg">
<StepsForm<InviteWizardValues>
initialValues={{ email: "", role: "member", message: "" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 300));
toast.success(`Invite queued for ${values.email}`);
}}
>
<StepsForm.Step name="basics" title="Basics">
<FormField
kind="email"
name="email"
label="Work email"
required
componentProps={{ placeholder: "name@company.com" }}
/>
</StepsForm.Step>
<StepsForm.Step name="access" title="Access">
<FormField
kind="select"
name="role"
label="Role"
required
componentProps={{
options: [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
],
}}
/>
</StepsForm.Step>
<StepsForm.Step name="message" title="Message">
<FormField
kind="textarea"
name="message"
label="Personal note"
componentProps={{
rows: 3,
placeholder: "Optional note in the invite email",
}}
/>
</StepsForm.Step>
</StepsForm>
</div>
);
}Composition
StepsForm
├── Stepper (Open; titles + current / maxReached)
└── Form (one Formily form)
├── FormErrorSummary (optional)
├── VoidField per Step (display visible | hidden)
│ └── FormField / SchemaField …
└── FormActions (Back + Next | Submit)API Reference
StepsForm Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | StepsForm.Step siblings |
form | Form<T> | internal | External Formily form; omit to create one |
initialValues | Partial<T> | — | Seeded when the internal form is created |
current | number | — | Controlled step index |
defaultCurrent | number | 0 | Uncontrolled initial step |
onCurrentChange | (current: number) => void | — | Fired when the step index changes |
onFinish | (values: T) => FormSubmitResult | Promise<…> | — | Whole-flow submit; void/resolve = success; { status: "error", issues } stays on last step |
onInvalid | () => void | — | Called when Next or Submit validation fails |
onSubmitError | (error: unknown) => void | — | Unexpected throws from onFinish |
focusOnInvalid | "summary" | "first-field" | false | "first-field" | Focus strategy after validation or known submit errors |
revealErrors | "touch" | "submit" | "always" | from Form | When invalid-field errors become visible |
errorSummary | false | — | Pass false to hide FormErrorSummary. Required when the step contains Editable Table — that table already owns a row rollup. Even if left on, FormErrorSummary strips Editable Table cell paths (kit 防呆); prefer false or root-only so the step does not rely on a hollow banner. |
submitter | StepsFormSubmitter | false | — | Footer config; false hides it |
t / locale | translator / string | — | Per-instance i18n overrides |
className | string | — | Root data-slot="steps-form" styling |
Layout props (orientation, labelWidth, labelAlign, colon, density, controlMaxWidth) are inherited from FormLayoutProps.
StepsForm.Step Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | VoidField address / validate scope |
title | ReactNode | — | Label in the Open Stepper |
description | ReactNode | — | Optional Stepper description |
children | ReactNode | — | Fields for this pane |
Submitter
| Key | Type | Description |
|---|---|---|
backText / nextText / submitText | ReactNode | Override built-in Back / Next / Submit labels |
render | (ctx) => ReactNode | Replace the footer; ctx includes current, stepCount, back, next, submit, submitting, defaultDom |