

`FormBuilder.Single` renders a complete form from a typed definition array (`formDefs`). It wires up TanStack Form, supports Zod validation, conditional visibility, multi-step wizards, and group layouts.

<Callout type="info" title="Need bulk ingestion?">
  For CSV import and multi-row editing, see [FormBuilder.Bulk](/composites/form-builder/bulk) instead.
</Callout>

## Quick Start [#quick-start]

<Steps>
  <Step>
    ### Define the schema [#define-the-schema]

    Model your payload with Zod and export it for reuse across form definitions, validation, and server handlers.

    ```tsx
    import {
      z } from "zod";

    export const memberSchema = z.object({
      firstName: z.string().min(1,
      "Required"),
      lastName: z.string().min(1,
      "Required"),
      email: z.string().email("Invalid email"),
      role: z.enum(["admin",
      "member"]).default("member"),
      });

    export type Member = z.infer<typeof memberSchema>;
    ```
  </Step>

  <Step>
    ### Compose formDefs [#compose-formdefs]

    Build a `formDefs` array describing your form's structure. See [Form Definitions](/composites/form-builder/form-defs) for the complete reference.

    ```tsx
    import type { FormDefs } from "@tilt-legal/cubitt-components/composites";
    import type {
      Member } from "./schema";

    export const formDefs = [
      { kind: "field",
      name: "firstName",
      label: "First name",
      component: "text",
      size: "half" },
      { kind: "field",
      name: "lastName",
      label: "Last name",
      component: "text",
      size: "half" },
      { kind: "field",
      name: "email",
      label: "Email",
      component: "email",
      size: "full" },
      {
        kind: "field",
      name: "role",
      label: "Role",
      component: "select",
      options: [
          { value: "admin",
      label: "Admin" },
      { value: "member",
      label: "Member" },
      ],
      size: "full",
      },
      ] as const satisfies FormDefs<Member>;
    ```
  </Step>

  <Step>
    ### Render and handle submission [#render-and-handle-submission]

    Render `FormBuilder.Single` with your schema and definitions. Return a `ZodError` from `onSubmit` when the server reports validation issues.

    ```tsx
    import { FormBuilder } from "@tilt-legal/cubitt-components/composites";
    import { memberSchema } from "./schema";
    import { formDefs } from "./form-defs";

    export function MemberForm() {
      return (
        <FormBuilder.Single
          schema={memberSchema}
          formDefs={formDefs}
          defaultValues={{ firstName: "", lastName: "", email: "", role: "member" }}
          onSubmit={async (values) => {
            const res = await createMember(values);
            if (res.error) return res.error; // Return ZodError for inline display
          }}
        />
      );
    }
    ```
  </Step>
</Steps>

## Examples [#examples]

### Basic Form [#basic-form]

<Tabs items="[&#x22;Usage&#x22;, &#x22;Schema&#x22;, &#x22;formDefs&#x22;]">
  <Tab>
    ```tsx
    <FormBuilder.Single
      schema={schema}
      formDefs={formDefs}
      defaultValues={{ email: "", password: "" }}
      title="Sign in"
      onSubmit={async (values) => {
        await signIn(values);
      }}
    />
    ```
  </Tab>

  <Tab>
    ```tsx
    const schema = z.object({
      email: z.string().email(),
      password: z.string().min(8, "At least 8 characters"),
    });
    ```
  </Tab>

  <Tab>
    ```tsx
    const formDefs = [
      { kind: "field", name: "email", label: "Email", component: "email", size: "full" },
      {
        kind: "field",
        name: "password",
        label: "Password",
        description: "At least 8 characters",
        component: "text",
        size: "full",
      },
    ] as const satisfies FormDefs;
    ```
  </Tab>
</Tabs>

### External Submit Button [#external-submit-button]

Use the `id` prop to connect an external submit button:

```tsx
const formId = "member-form";

<FormBuilder.Single
  id={formId}
  schema={schema}
  formDefs={formDefs}
  onSubmit={handleSubmit}
/>

<Button type="submit" form={formId}>
  Save Member
</Button>
```

Or use `submitRef` for imperative control:

```tsx
const submitRef = useRef<() => void>(null);

<FormBuilder.Single
  submitRef={submitRef}
  schema={schema}
  formDefs={formDefs}
  onSubmit={handleSubmit}
/>

<Button onClick={() => submitRef.current?.()}>
  Save
</Button>
```

### Multi-Step Wizard [#multi-step-wizard]

Wrap fields in `step` nodes to create a wizard flow:

```tsx
const formDefs = [
  {
    kind: "step",
    id: "contact",
    title: "Contact Info",
    children: [
      { kind: "field", name: "firstName", label: "First name", component: "text", size: "half" },
      { kind: "field", name: "lastName", label: "Last name", component: "text", size: "half" },
    ],
  },
  {
    kind: "step",
    id: "account",
    title: "Account",
    children: [
      { kind: "field", name: "email", label: "Email", component: "email", size: "full" },
      { kind: "field", name: "password", label: "Password", component: "text", size: "full" },
    ],
  },
] as const satisfies FormDefs;
```

### Custom Stepper UI [#custom-stepper-ui]

Use `FormBuilder.Single.useStepper()` to build custom navigation:

```tsx
function CustomWizard() {
  const stepper = FormBuilder.Single.useStepper();

  return (
    <div className="space-y-4">
      {/* Progress indicator */}
      <div className="flex gap-2">
        {Array.from({ length: stepper.state.total }, (_, i) => (
          <div
            key={i}
            className={cn(
              "h-2 flex-1 rounded",
              i <= stepper.state.index ? "bg-primary" : "bg-muted"
            )}
          />
        ))}
      </div>

      {/* Form */}
      <FormBuilder.Single
        schema={schema}
        formDefs={formDefs}
        stepper={stepper.render}
        onSubmit={handleSubmit}
      />

      {/* Custom navigation */}
      <div className="flex justify-between">
        <Button
          variant="outline"
          disabled={stepper.state.disabled || stepper.state.isFirst}
          onClick={stepper.actions.previous}
        >
          Back
        </Button>
        {stepper.state.isLast ? (
          <Button disabled={stepper.state.disabled} onClick={stepper.actions.submit}>
            Submit
          </Button>
        ) : (
          <Button disabled={stepper.state.disabled} onClick={stepper.actions.next}>
            Next
          </Button>
        )}
      </div>
    </div>
  );
}
```

## Customising Layout [#customising-layout]

Pass `groupClassName` and `itemClassName` to style the form structure:

```tsx
<FormBuilder.Single
  schema={schema}
  formDefs={formDefs}
  groupClassName="bg-muted/40 p-6 rounded-xl"
  itemClassName="min-h-[88px]"
  footer={<InlineHelp />}
  onSubmit={handleSubmit}
/>
```

For object-level validation that should appear once under a fieldset, add
`name` to the relevant `group` node and raise the Zod issue at that same object
path. See [Group Nodes](/composites/form-builder/form-defs#group-nodes) and
[Validation](/composites/form-builder/validation#group-level-object-errors).

## API Reference [#api-reference]

| Prop             | Type                              | Default    | Description                                                                                  |
| ---------------- | --------------------------------- | ---------- | -------------------------------------------------------------------------------------------- |
| `schema`         | `z.ZodTypeAny`                    | —          | Zod schema used for validation and type inference (required)                                 |
| `formDefs`       | `FormDefs<T>`                     | —          | Array of step, group, and field nodes describing the form structure (required)               |
| `onSubmit`       | `(values: T) => FormSubmitResult` | —          | Async handler. Return nothing on success or a ZodError for validation failures (required)    |
| `defaultValues`  | `Partial<z.infer<typeof schema>>` | —          | Initial values merged into TanStack Form state                                               |
| `defaultStep`    | `number \| string`                | —          | Initial step (index or step.id) when using step nodes                                        |
| `validateOnBack` | `boolean`                         | `false`    | When true, validates the current step before moving backwards                                |
| `title`          | `string`                          | —          | Heading rendered above the form                                                              |
| `description`    | `string`                          | —          | Description rendered under the heading                                                       |
| `disabled`       | `boolean`                         | `false`    | Disables built-in field interactions, stepper actions, and the default submit button         |
| `id`             | `string`                          | —          | Forwarded to the underlying `<form>`; pair with external `<button form="…">`                 |
| `submitLabel`    | `string \| null`                  | `"Submit"` | Label for the built-in submit button. Pass null to hide                                      |
| `footer`         | `ReactNode`                       | —          | React node rendered after all fields inside the form                                         |
| `stepper`        | `false \| StepperRender<T>`       | —          | Control the wizard UI: omit for default, set false to hide, or pass a custom render function |
| `groupClassName` | `string`                          | —          | CSS classes applied to group wrappers                                                        |
| `itemClassName`  | `string`                          | —          | CSS classes applied to individual field containers                                           |
| `className`      | `string`                          | —          | CSS classes applied to the form element                                                      |
| `submitRef`      | `Ref<() => void>`                 | —          | Imperative ref exposing `() => void` to trigger submission                                   |
| `formRef`        | `Ref<AnyReactFormApi>`            | —          | Ref to access the underlying TanStack Form instance                                          |

## Stepper Hook [#stepper-hook]

`FormBuilder.Single.useStepper()` returns a handle for external control:

| Property           | Type                           | Description                                                                |
| ------------------ | ------------------------------ | -------------------------------------------------------------------------- |
| `state.index`      | `number`                       | Current step index (0-based)                                               |
| `state.total`      | `number`                       | Total number of visible steps                                              |
| `state.isFirst`    | `boolean`                      | Whether on the first step                                                  |
| `state.isLast`     | `boolean`                      | Whether on the last step                                                   |
| `state.disabled`   | `boolean`                      | Mirrors the `disabled` prop so custom steppers can lock their own controls |
| `state.step`       | `Step \| null`                 | Current step definition                                                    |
| `actions.next`     | `() => Promise<boolean>`       | Advance to next step (validates first)                                     |
| `actions.previous` | `() => Promise<boolean>`       | Go to previous step                                                        |
| `actions.goTo`     | `(target) => Promise<boolean>` | Jump to step by index or id                                                |
| `actions.submit`   | `() => Promise<boolean>`       | Trigger form submission                                                    |
| `actions.reset`    | `() => void`                   | Reset to first step                                                        |
| `render`           | `(slot) => ReactNode`          | Pass to `stepper` prop                                                     |
