Modal Steps Form
A multi-step Formily wizard in a modal with overlay open, reset, and Dialog footer actions.
Plus Registry
Modal Steps Form is a Plus-registry component. See Installation — Plus Registry.
Modal Steps Form is a short multi-step create/edit wizard inside a dialog: Steps Form progress and gates, Modal Form–style open / seed / close lifecycle, and Back / Next / Submit pinned in the dialog footer — Carbon’s progress-modal pattern without hand-rolling Dialog + wizard reset.
One Formily Form
Same contract as Steps Form: one Formily form for the whole wizard. ModalStepsForm.Step is StepsForm.Step.
When To Use
- Short, linear create/edit from a list — roughly ≤ 4 steps, few fields per pane — where the user should finish or cancel before returning to the list (Carbon progress modal).
- When Modal Form is too small (multi-screen gates) but a full page wizard is overkill.
- Use Steps Form (page / embedded) instead for long flows, branching, provisioning, or when the user may leave mid-flow via navigation.
- Use Form Page instead for multi-section create/edit on one page (drafts, unsaved guards, line items).
- Do not stuff a long or branching wizard into a dialog — prefer page Steps Form.
Features
| Area | Behavior |
|---|---|
| Overlay | trigger or controlled open / onOpenChange; initialValues seeded on open via useOverlayForm |
| Progress | Open Stepper via Steps Form; Next validates the current step only |
| Footer | Back / Next / Submit live in DialogFooter (Steps Form layoutRender seam) |
| Reset | Default preserve={false}: close → reopen returns to step 0 with a cleared form. With controlled current, the shell still calls onCurrentChange(0) on dismiss/reopen — hosts must accept that reset. |
| Submit | Last step runs whole-flow onFinish; success closes when closeOnSuccess (default true) |
| Busy | Footer + dismiss disabled while submitting or advancing |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.jsonnpx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.jsonbunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.jsonInstalls under the @f-ui-plus/modal-steps-form namespace. Depends on @f-ui-plus/steps-form and the shadcn dialog / button primitives.
Usage
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalStepsForm } from "@/components/f-ui/modal-steps-form/modal-steps-form";
import { Button } from "@/components/ui/button";
<ModalStepsForm
trigger={<Button>New resource</Button>}
title="New resource"
initialValues={{ email: "", role: "member", notes: "" }}
onFinish={async (values) => {
await save(values); // resolve → modal closes
}}
>
<ModalStepsForm.Step name="email" title="Email">
<FormField kind="email" name="email" label="Work email" required />
</ModalStepsForm.Step>
<ModalStepsForm.Step name="role" title="Role">
<FormField kind="select" name="role" label="Role" required /* … */ />
</ModalStepsForm.Step>
<ModalStepsForm.Step name="notes" title="Notes">
<FormField kind="textarea" name="notes" label="Notes" />
</ModalStepsForm.Step>
</ModalStepsForm>;Examples
Default
Open New resource, fill Email → Role → Notes, then Submit. Back keeps earlier values; the footer sits under the dialog chrome, not inline under the fields.
"use client";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalStepsForm } from "@/components/f-ui/modal-steps-form/modal-steps-form";
import { Button } from "@/components/ui/button";
interface NewResourceValues {
email: string;
role: string;
notes: string;
}
const ROLE_OPTIONS = [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
];
/**
* Short overlay wizard: New resource → Email → Role → Notes → Submit.
*/
export function ModalStepsFormDemo() {
return (
<div className="w-full">
<ModalStepsForm<NewResourceValues>
trigger={<Button>New resource</Button>}
title="New resource"
description="Invite someone with a short three-step flow"
initialValues={{ email: "", role: "member", notes: "" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 400));
toast.success(`Created invite for ${values.email}`);
console.log("ModalStepsForm onFinish", values);
}}
>
<ModalStepsForm.Step name="email" title="Email">
<FormField
kind="email"
name="email"
label="Work email"
required
componentProps={{ placeholder: "name@company.com" }}
/>
</ModalStepsForm.Step>
<ModalStepsForm.Step name="role" title="Role">
<FormField
kind="select"
name="role"
label="Role"
required
componentProps={{ options: ROLE_OPTIONS }}
/>
</ModalStepsForm.Step>
<ModalStepsForm.Step name="notes" title="Notes">
<FormField
kind="textarea"
name="notes"
label="Notes"
componentProps={{
placeholder: "Optional context for the team",
rows: 3,
}}
/>
</ModalStepsForm.Step>
</ModalStepsForm>
</div>
);
}Reset On Close
Fill Work email, click Next, then press Esc (or close the dialog). Reopen — you land on Email with an empty field. Default preserve={false} resets both step index and form values so mid-flow dismiss does not leave a stale wizard.
"use client";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalStepsForm } from "@/components/f-ui/modal-steps-form/modal-steps-form";
import { Button } from "@/components/ui/button";
interface NewResourceValues {
email: string;
role: string;
notes: string;
}
const ROLE_OPTIONS = [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
];
/**
* Same three-step shape as the default demo — used to prove close/reopen reset.
* MDX lead-in: fill Email, Next, Esc, reopen → Email empty on step 0.
*/
export function ModalStepsFormResetDemo() {
return (
<div className="w-full">
<ModalStepsForm<NewResourceValues>
trigger={<Button>New resource</Button>}
title="New resource"
description="Dismiss mid-flow to verify step and values reset"
initialValues={{ email: "", role: "member", notes: "" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 400));
toast.success(`Created invite for ${values.email}`);
}}
>
<ModalStepsForm.Step name="email" title="Email">
<FormField
kind="email"
name="email"
label="Work email"
required
componentProps={{ placeholder: "name@company.com" }}
/>
</ModalStepsForm.Step>
<ModalStepsForm.Step name="role" title="Role">
<FormField
kind="select"
name="role"
label="Role"
required
componentProps={{ options: ROLE_OPTIONS }}
/>
</ModalStepsForm.Step>
<ModalStepsForm.Step name="notes" title="Notes">
<FormField
kind="textarea"
name="notes"
label="Notes"
componentProps={{
placeholder: "Optional context for the team",
rows: 3,
}}
/>
</ModalStepsForm.Step>
</ModalStepsForm>
</div>
);
}Composition
ModalStepsForm
├── Dialog (open / onOpenChange)
│ └── DialogContent (default max-width 720px)
│ ├── DialogHeader (title, description)
│ └── StepsForm (layoutRender)
│ ├── scroll body (Stepper + VoidField panes)
│ └── DialogFooter (Back + Next | Submit)API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | ModalStepsForm.Step / StepsForm.Step siblings |
form | Form<T> | internal | External Formily form; omit to create one |
initialValues | Partial<T> | — | Seeded into the form on each open |
trigger | ReactElement | — | Element that opens the modal on click |
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Uncontrolled initial open |
onOpenChange | (open: boolean) => void | — | Open-state change callback |
title | ReactNode | — | Modal title |
description | ReactNode | — | Modal description |
width | number | string | 720 | Max width; number → px |
modalProps | DialogContent props | — | Passed through to the dialog content |
current | number | — | Controlled step index; with preserve={false}, dismiss/reopen still drives onCurrentChange(0) — hosts must apply it |
defaultCurrent | number | 0 | Uncontrolled initial step |
onCurrentChange | (current: number) => void | — | Fired when the step index changes (including shell reset to 0 when preserve={false}) |
onFinish | (values: T) => FormSubmitResult | Promise<…> | — | Whole-flow submit; void/resolve closes when closeOnSuccess |
closeOnSuccess | boolean | true | Close after a successful onFinish |
preserve | boolean | false | Keep values and step index on close |
loading | boolean | false | Force-disable footer / dismiss |
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 |
submitter | StepsFormSubmitter | false | — | Footer config; false hides it |
t / locale | translator / string | — | Per-instance i18n overrides (Steps Form keys) |
className / classNames | string / slot map | — | Styling (content / header / body / footer); classNames.body targets the Steps Form host, not Modal Form’s inner Form |
Layout props (orientation, labelWidth, labelAlign, colon, density, controlMaxWidth) are inherited from FormLayoutProps. Step pane props match Steps Form — StepsForm.Step.
Slots
content · header · body · footer — target via classNames.
See also Steps Form, Modal Form, and Stepper.