Single
Schema-driven single-record forms with multi-step wizard support.
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.
Need bulk ingestion?
For CSV import and multi-row editing, see FormBuilder.Bulk instead.
Quick Start
Define the schema
Model your payload with Zod and export it for reuse across form definitions, validation, and server handlers.
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>;Compose formDefs
Build a formDefs array describing your form's structure. See Form Definitions for the complete reference.
import type { FormDefs } from "@tilt-legal/cubitt-components/form-builder";
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>;Render and handle submission
Render FormBuilder.Single with your schema and definitions. Return a ZodError from onSubmit when the server reports validation issues.
import { FormBuilder } from "@tilt-legal/cubitt-components/form-builder";
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
}}
/>
);
}Examples
Basic Form
<FormBuilder.Single
schema={schema}
formDefs={formDefs}
defaultValues={{ email: "", password: "" }}
title="Sign in"
onSubmit={async (values) => {
await signIn(values);
}}
/>const schema = z.object({
email: z.string().email(),
password: z.string().min(8, "At least 8 characters"),
});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;External Submit Button
Use the id prop to connect an external submit button:
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:
const submitRef = useRef<() => void>(null);
<FormBuilder.Single
submitRef={submitRef}
schema={schema}
formDefs={formDefs}
onSubmit={handleSubmit}
/>
<Button onClick={() => submitRef.current?.()}>
Save
</Button>Multi-Step Wizard
Wrap fields in step nodes to create a wizard flow:
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
Use FormBuilder.Single.useStepper() to build custom navigation:
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
Pass groupClassName and itemClassName to style the form structure:
<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 and
Validation.
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
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 |