f-ui
Components

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

AreaBehavior
ProgressOpen Stepper (horizontal); click jumps only to indices ≤ maxReached; Next/Submit validation failure marks the current step error
VMOne Formily form; each StepsForm.Step is a pane (VoidField), not a second form
NextValidates the current step only; advances on success
BackDecrements step index; keeps values; no validate
Step clickJump only to indices ≤ maxReached (no skipping ahead)
SubmitLast step: validate all steps → onFinish(values); issues / throw stay on last step
SubmitterDefault 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.json
npx shadcn@latest add https://ui.isaacfei.com/api/plus/r/steps-form.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/steps-form.json
bunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/steps-form.json

Installs 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

PropTypeDefaultDescription
childrenReactNodeStepsForm.Step siblings
formForm<T>internalExternal Formily form; omit to create one
initialValuesPartial<T>Seeded when the internal form is created
currentnumberControlled step index
defaultCurrentnumber0Uncontrolled initial step
onCurrentChange(current: number) => voidFired when the step index changes
onFinish(values: T) => FormSubmitResult | Promise<…>Whole-flow submit; void/resolve = success; { status: "error", issues } stays on last step
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. 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.
submitterStepsFormSubmitter | falseFooter config; false hides it
t / localetranslator / stringPer-instance i18n overrides
classNamestringRoot data-slot="steps-form" styling

Layout props (orientation, labelWidth, labelAlign, colon, density, controlMaxWidth) are inherited from FormLayoutProps.

StepsForm.Step Props

PropTypeDefaultDescription
namestringVoidField address / validate scope
titleReactNodeLabel in the Open Stepper
descriptionReactNodeOptional Stepper description
childrenReactNodeFields for this pane

Submitter

KeyTypeDescription
backText / nextText / submitTextReactNodeOverride built-in Back / Next / Submit labels
render(ctx) => ReactNodeReplace the footer; ctx includes current, stepCount, back, next, submit, submitting, defaultDom

On this page