Long forms convert better when they are split into short steps: account first, then profile, then preferences. The tricky parts are validating only the current step, keeping everything the user typed when they go back, and showing where they are. oks-ui's SteppedForm handles all three.
import { SteppedForm } from "oks-ui/stepped-form";
import "oks-ui/stepped-form.css";
import { FormFieldSet } from "oks-ui/form-field-set";
import "oks-ui/form-field-set.css";1. Define the steps
Each step has a key, a title, its content, and a fields list — the names of the fields that must be valid before the user can press Next.
export function SignupWizard() {
return (
<SteppedForm
onSubmit={(data) => createAccount(data)}
steps={[
{
key: "account",
title: "Account",
fields: ["email", "password"],
content: (
<>
<FormFieldSet type="email" name="email" label="Email"
validation={{ rules: { required: true, email: true } }} />
<FormFieldSet type="password" name="password" label="Password"
validation={{ rules: { required: true, minLength: 8 } }} />
</>
),
},
{
key: "profile",
title: "Profile",
fields: ["fullName", "company"],
content: (
<>
<FormFieldSet type="text" name="fullName" label="Full name"
validation={{ rules: { required: true } }} />
<FormFieldSet type="text" name="company" label="Company" />
</>
),
},
{
key: "preferences",
title: "Preferences",
isOptional: true,
content: (
<FormFieldSet type="switch" name="newsletter" label="Send me product updates" />
),
},
]}
/>
);
}Pressing Next validates only the current step's fields. Back keeps every value, and the final Submit validates the whole form before calling onSubmit with all the data together.
2. Choose a progress header
headerVariant sets how progress is shown:
"dots"(default) — numbered steps with titles."progress"— a progress bar."tabs"— step titles as tabs."none"— no header, when you draw your own.
<SteppedForm headerVariant="progress" steps={steps} onSubmit={submit} />By default users can click the header to jump back to earlier steps. disableHeaderNavigation turns that off for strictly linear flows.
3. Check something on the server before moving on
Some checks can't be expressed as field rules — "is this email already registered?" A step's onValidate runs after its fields pass. Return true to continue, or a string to stop with that message:
{
key: "account",
title: "Account",
fields: ["email", "password"],
content: /* …fields… */,
onValidate: async (data) => {
const taken = await isEmailTaken(String(data.email));
return taken ? "That email already has an account" : true;
},
}While the check runs, Next shows a loading state and both buttons are disabled, so it can't be sent twice. Note that onValidate only runs on Next; do final server checks in onSubmit.
4. Change the button labels
<SteppedForm backLabel="Previous" nextLabel="Continue" submitLabel="Create account" steps={steps} onSubmit={submit} />5. Control the step yourself
To keep the current step in the URL, or move between steps from outside the form, control it with stepIndex and onStepChange:
const [step, setStep] = useState(0);
<SteppedForm stepIndex={step} onStepChange={setStep} steps={steps} onSubmit={submit} />6. Your own header design
For a fully custom look, renderHeader receives the steps and the active index:
<SteppedForm
headerVariant="none"
renderHeader={({ steps, activeStepIndex }) => (
<p>Step {activeStepIndex + 1} of {steps.length}: {steps[activeStepIndex].title}</p>
)}
steps={steps}
onSubmit={submit}
/>Tips for good multi-step forms
- Keep it to three to five steps, each with a clear title.
- Put the most important fields first, so an abandoned form still collected them.
- Mark truly optional steps with
isOptional.
See a complete example on the onboarding wizard pattern.


