Multi-Step Wizard
Multi-step form with custom external navigation controls.
"use client";
import { Button } from "@tilt-legal/cubitt-components/button";
import { z } from "zod";
import { FormBuilder } from "@tilt-legal/cubitt-components/form-builder";
const schema = z.object({
client: z.object({
name: z.string().min(1, "Add a client name"),
email: z.string().email("Enter a valid email"),
}),
engagement: z.object({
type: z.enum(["standard", "custom"]),
startDate: z.date(),
notes: z.string().optional(),
}),
confirm: z.boolean().refine(Boolean, "Please confirm before submitting"),
});
type WizardValues = z.infer<typeof schema>;
const formDefs = [
{
kind: "step",
id: "client",
title: "Client details",
nextLabel: "Next: Engagement",
children: [
{
kind: "group",
children: [
{
kind: "field",
name: "client.name",
label: "Client name",
component: "text",
size: "half",
},
{
kind: "field",
name: "client.email",
label: "Client email",
component: "email",
size: "half",
},
],
},
],
},
{
kind: "step",
id: "engagement",
title: "Engagement setup",
nextLabel: "Review",
children: [
{
kind: "group",
title: "Engagement details",
children: [
{
kind: "field",
name: "engagement.type",
label: "Type",
component: "radio",
options: [
{ value: "standard", label: "Standard" },
{ value: "custom", label: "Custom" },
],
},
{
kind: "field",
name: "engagement.startDate",
label: "Start date",
component: "date",
size: "half",
},
{
kind: "field",
name: "engagement.notes",
label: "Customization notes",
component: "textarea",
size: "full",
showIf: (v: WizardValues) => v.engagement?.type === "custom",
},
],
},
],
},
{
kind: "step",
title: "Review & confirm",
previousLabel: "Back",
nextLabel: "Submit",
children: [
{
kind: "group",
children: [
{
kind: "field",
name: "confirm",
label: "I confirm these details are correct",
component: "checkbox",
},
],
},
],
},
] as const satisfies FormDefs<WizardValues>;
export default function WizardForm() {
const stepper = FormBuilder.Single.useStepper<WizardValues>(() => null);
return (
<div>
<FormBuilder.Single
schema={schema}
formDefs={formDefs}
defaultValues={{
client: { name: "", email: "" },
engagement: { type: "standard", startDate: new Date(), notes: "" },
confirm: false,
}}
stepper={stepper.render}
validateOnBack
onSubmit={async (values) => console.log(values)}
/>
<div className="mt-6 flex justify-end gap-2">
<Button
variant="secondary"
disabled={stepper.state.isFirst}
onClick={() => stepper.actions.previous()}
>
Back
</Button>
<Button
onClick={() =>
stepper.state.isLast
? stepper.actions.submit()
: stepper.actions.next()
}
>
{stepper.state.isLast ? "Submit" : "Next"}
</Button>
</div>
</div>
);
}How It Works
- Define step nodes – Use
kind: "step"withid,title, andchildren - Get stepper handle – Call
FormBuilder.Single.useStepper(() => null)to hide default UI - Pass stepper.render – Connects the stepper to the form
- Build custom controls – Use
stepper.stateandstepper.actions
See Step Nodes for step properties and Stepper Hook for the full state/actions API.