oks-ui

How to use

SteppedForm

SteppedForm is a linear multi-step wizard built on top of the existing Form/FormFieldSet system — it inherits every Form prop (onSubmit, validationMode, initialValues, and so on) and adds step orchestration on top: per-step field validation before advancing, four built-in header styles, and full support for async per-step checks.

import { SteppedForm, FormFieldSet } from "oks-ui";

Defining steps

Each step needs a unique key, a title for the header, the field names it should validate before letting the user advance, and its actual content (typically a group of FormFieldSet fields). All fields across every step register with the form immediately, even on steps not yet visited — so the final submit's full-schema validation always sees the whole form, not just whichever step is currently showing.

<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: ["firstName", "lastName"],
      content: (
        <>
          <FormFieldSet type="text" name="firstName" label="First name" validation={{ rules: { required: true } }} />
          <FormFieldSet type="text" name="lastName" label="Last name" validation={{ rules: { required: true } }} />
        </>
      ),
    },
  ]}
/>

Header styles

headerVariant picks a built-in header — dots (compact, the default), progress (a filled progress bar), tabs (clickable step labels), or none if you're rendering your own step indicator elsewhere. disableHeaderNavigation stops the header itself from being a way to jump between steps, useful for a strictly linear flow.

<SteppedForm headerVariant="progress" steps={steps} onSubmit={onSubmit} />

Async per-step validation

Beyond field-level validation rules, a step's onValidate runs a sync or async check before the user is allowed past it — return true to advance, or a string (or false) to block, with the string becoming the step's error message. While it's pending, Next shows a loading state and both Back/Next disable to prevent a double-submit.

onValidate only gates advancing via Next, not the final Submit — Form's own full-schema validation already covers every field on submit. If a step's async check should also block the final submission, do that inside onSubmit itself.

{
  key: "account",
  title: "Account",
  fields: ["email"],
  content: <FormFieldSet type="email" name="email" label="Email" validation={{ rules: { required: true, email: true } }} />,
  onValidate: async (formData) => {
    const available = await checkEmailAvailable(formData.email as string);
    return available || "That email is already taken";
  },
}

Syncing the active step to the URL

SteppedForm stays router-agnostic on purpose — use the controlled stepIndex/onStepChange pair to sync the active step with whatever URL approach your app already uses (the History API directly, or your router's own navigation call).

const [stepIndex, setStepIndex] = useState(() => {
  const n = Number(new URLSearchParams(window.location.search).get("step"));
  return Number.isFinite(n) && n >= 0 ? n : 0;
});

useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  params.set("step", String(stepIndex));
  window.history.replaceState(null, "", `?${params}`);
}, [stepIndex]);

<SteppedForm stepIndex={stepIndex} onStepChange={setStepIndex} steps={steps} onSubmit={onSubmit} />

Accessibility

  • The active step in the header carries aria-current="step".
  • Future, not-yet-reached steps are disabled in the default linear mode — not clickable until the user has actually reached them, which keeps keyboard/tab order matching the visual flow.
  • Back/Next/Submit render with correct native button types (button for Back/Next, submit for the final step) so Enter inside a field behaves the way a user expects.
  • A step's onValidate error message renders with role="alert", the same as ordinary field-level validation errors.

Props reference

All available props for SteppedForm. See the full component page for an interactive playground.

Props

steps

SteppedFormStep[]

Default:

Ordered step definitions: { key, title, description?, fields?, content, isOptional? } (required).

backLabel / nextLabel / submitLabel

ReactNode

Default: "Back" / "Next" / "Submit"

Footer action button labels.

Best practices

  • Keep each step's fields array accurate — it's what gets validated before Next is allowed, so a field left out of it can silently be skipped past while still invalid.
  • Use headerVariant="tabs" only for flows where jumping between steps out of order genuinely makes sense; for a strictly sequential flow (checkout, onboarding), dots or progress read more honestly.
  • Reach for onValidate for checks that need a network round-trip (username/email availability); keep everything checkable synchronously in the field's own validation rules instead, since it runs without the extra async overhead.
  • Don't try to gate final submission through a step's onValidate — it only runs on Next. Put submission-blocking async checks inside onSubmit itself.
View full API reference for SteppedForm