f-ui
Components

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

AreaBehavior
Overlaytrigger or controlled open / onOpenChange; initialValues seeded on open via useOverlayForm
ProgressOpen Stepper via Steps Form; Next validates the current step only
FooterBack / Next / Submit live in DialogFooter (Steps Form layoutRender seam)
ResetDefault 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.
SubmitLast step runs whole-flow onFinish; success closes when closeOnSuccess (default true)
BusyFooter + dismiss disabled while submitting or advancing

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.json
npx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.json
bunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/modal-steps-form.json

Installs 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

PropTypeDefaultDescription
childrenReactNodeModalStepsForm.Step / StepsForm.Step siblings
formForm<T>internalExternal Formily form; omit to create one
initialValuesPartial<T>Seeded into the form on each open
triggerReactElementElement that opens the modal on click
openbooleanControlled open state
defaultOpenbooleanfalseUncontrolled initial open
onOpenChange(open: boolean) => voidOpen-state change callback
titleReactNodeModal title
descriptionReactNodeModal description
widthnumber | string720Max width; number → px
modalPropsDialogContent propsPassed through to the dialog content
currentnumberControlled step index; with preserve={false}, dismiss/reopen still drives onCurrentChange(0) — hosts must apply it
defaultCurrentnumber0Uncontrolled initial step
onCurrentChange(current: number) => voidFired 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
closeOnSuccessbooleantrueClose after a successful onFinish
preservebooleanfalseKeep values and step index on close
loadingbooleanfalseForce-disable footer / dismiss
onInvalid() => voidCalled when Next or Submit validation fails
onSubmitError(error: unknown) => voidUnexpected throws from onFinish
focusOnInvalid"summary" | "first-field" | false"first-field"Focus strategy after validation or known submit errors
revealErrors"touch" | "submit" | "always"from FormWhen invalid-field errors become visible
errorSummaryfalsePass false to hide FormErrorSummary
submitterStepsFormSubmitter | falseFooter config; false hides it
t / localetranslator / stringPer-instance i18n overrides (Steps Form keys)
className / classNamesstring / slot mapStyling (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.

On this page