Skip to content

Wizard ​

Split a long form into ordered steps. Steps validate as the user moves through them, a stepper tracks progress and lets you jump to any step, and a final review page recaps every answer with edit links before submit.

What It Demonstrates ​

  • The wizard metadata property: the marked node's children become the steps, in declaration order
  • A non-linear wizard (allowForwardJump): next() and stepper jumps surface each step's errors but never block, so the user can move freely and fix things in any order
  • A stepper that reflects the current step, jumps to any step, forward or back, and turns a step red when it has validation errors
  • A final step whose submit button is disabled while any step still has errors, with a message pointing at the red steps
  • A wizardSummaryPage step that reviews the collected values with per-section edit links
  • wizardTexts.submitButtonText for the label on the final step's submit button

Example ​

Event Registration

Reserve your spot

Attendee

Who is attending?

Ticket

Choose what you are booking.

Preferences

Optional extras, skip anything that does not apply.

Review & submit

Check everything before you register. Jump to any step to make a change.

Attendee

Full name
—
Email
—

Ticket

Type
—
Quantity
—

Preferences

Dietary
—
Newsletter
No

Ready to submit?

We'll email your ticket and a calendar invite to confirm your spot.

Step 1 of 4 · Your details

IsDirty: false
Touched: false
Valid: true

// form values:
{
  "attendee": {},
  "ticket": {},
  "preferences": {}
}
    

How It Works ​

Mark a node with wizard. Its children become the steps, each one a heading group with its own fields. This example passes wizard: { allowForwardJump: true } so the user can move through the steps freely; the last step is a wizardSummaryPage that shows a read-only recap:

ts
const metadata: Metadata[] = [
  {
    name: 'registration',
    path: '',
    wizard: { allowForwardJump: true },
    fieldOptions: { label: formName },
    description: 'Reserve your spot',
    wizardTexts: { submitButtonText: 'Complete registration' },
    children: [
      {
        name: 'attendee',
        type: 'heading',
        fieldOptions: { label: 'Attendee' },
        description: 'Who is attending?',
        helpText: 'Your details',
        children: [
          {
            name: 'fullName',
            fieldOptions: { label: 'Full name' },
            restriction: { minLength: 2 },
            colSpan: 'full',
          },
          {
            name: 'email',
            fieldOptions: { label: 'Email' },
            restriction: { pattern: '^.+@.+\\..+$' },
            colSpan: 'full',
          },
        ],
      },
      {
        name: 'ticket',
        type: 'heading',
        fieldOptions: { label: 'Ticket' },
        description: 'Choose what you are booking.',
        helpText: 'Pick a ticket',
        children: [
          {
            name: 'ticketType',
            type: 'select',
            fieldOptions: { label: 'Ticket type' },
            options: ticketOptions,
          },
          {
            name: 'quantity',
            type: 'select',
            fieldOptions: { label: 'Quantity' },
            options: [
              { key: '1', value: '1' },
              { key: '2', value: '2' },
              { key: '3', value: '3' },
            ],
          },
        ],
      },
      {
        name: 'preferences',
        type: 'heading',
        fieldOptions: { label: 'Preferences' },
        description: 'Optional extras, skip anything that does not apply.',
        helpText: 'Fine-tune',
        children: [
          {
            name: 'dietary',
            type: 'select',
            minOccurs: 0,
            fieldOptions: { label: 'Dietary requirements' },
            options: dietaryOptions,
          },
          {
            name: 'newsletter',
            type: 'checkbox',
            minOccurs: 0,
            fieldOptions: { label: 'Send me event updates by email' },
            colSpan: 'full',
          },
        ],
      },
      {
        name: 'summaryPage',
        type: 'wizardSummaryPage',
        fieldOptions: { label: 'Review & submit' },
        description: 'Check everything before you register. Jump to any step to make a change.',
        helpText: 'Confirm',
        minOccurs: 0,
        computedProps: [(field) => { field.wizardSummary = { groups: wizardSummary.value, confirmation: 'We\'ll email your ticket and a calendar invite to confirm your spot.' }; }],
      },
    ],
  },
];

The engine hands the template all step state and navigation (currentStepIndex, pages, isFirst, isLast, next(), prev(), gotoStep()) as slot props on the -wizard / -wizard-page slot family. The docs template forwards these to its FormWizard and Stepper components, so the wizard chrome lives entirely in template code, not in the library. See Wizard container and page slots for the full slot contract.

Pages must stay mounted

Wizard page slots gate visibility with v-show, never v-if. Every page stays mounted at all times so its fields keep their values and validation when the user navigates away. A v-if would clear that page's data (unless keepValuesOnUnmount is set) and, regardless of that setting, deregister its fields from vee-validate, so a later submit could send the page's data unvalidated even when its values survived. Because keepValuesOnUnmount preserves only the values and not the field registration, v-show is still required for any page that has fields.

Linear vs non-linear ​

The example above is non-linear because it sets allowForwardJump. Without it, wizard: true gives a linear wizard: next() blocks on an invalid step, and the stepper only jumps backward to already-visited steps, so the user must complete each step in order before moving on.

ts
const registration = {
  name: 'registration',
  wizard: true, // linear: next() blocks, stepper is backward-only
  children: [/* ... */],
};

allowForwardJump makes forward movement non-blocking: both next() and forward jumps from the stepper surface the current step's errors but let the user continue anyway. Validation still fires so errors stay visible; to move without validating at all, add validateOnJump: false for a silent jump.

Showing Step Errors ​

A non-linear wizard lets the user walk past invalid steps, so the stepper doubles as the error overview: a step whose fields have validation errors turns red, and the final step's submit button is disabled (with a message) until every error is fixed.

The engine does not track per-step validity; the template derives it. Each page in the pages slot prop carries its path, and vee-validate exposes all current errors keyed by field path via useFormErrors(), so the template marks a step as erroneous when any error path falls at or under the page's path (the exported isPathInSection helper does that matching, the same one validateSection uses internally):

ts
const formErrors = useFormErrors();

// A wizard page is in error when any registered field at or under its path currently has a
// validation error.
function pageHasErrors(pagePath: string): boolean {
  return Object.entries(formErrors.value).some(([path, message]) =>
    !!message && isPathInSection(path, pagePath));
}

Errors only exist once a field has been validated, so steps turn red exactly as validation surfaces them: when next() or a forward jump validates the page being left, or when a submit attempt validates the whole form. A not-yet-visited step is never falsely red, and a red step clears as soon as its fields are fixed, re-enabling the submit button.

The Review Page ​

The final step is a wizardSummaryPage. A computedProps function feeds its wizardSummary.groups, and the template renders one review group per section with an edit link that calls gotoStep():

ts
const wizardSummary = computed<ReviewGroupProps[]>(() => [
  {
    title: 'Attendee',
    rows: [
      ['Full name', values?.attendee?.fullName || '—'],
      ['Email', values?.attendee?.email || '—'],
    ],
  },
  {
    title: 'Ticket',
    rows: [
      ['Type', ticketLabel.value || '—'],
      ['Quantity', values?.ticket?.quantity || '—'],
    ],
  },
  {
    title: 'Preferences',
    rows: [
      ['Dietary', dietaryLabel.value || '—'],
      ['Newsletter', values?.preferences?.newsletter ? 'Subscribed' : 'No'],
    ],
  },
]);

Handling Submit ​

The final step's button is a native type="submit" control, so the wrapping form forwards its submit event to your own handleSubmit from useDynamicForm:

ts
function reset() {
  resetForm();
  submitted.value = undefined;
}

// The wizard's last-page button is a native type="submit" control, so AdvancedForm forwards the
// form's own submit event here for handleSubmit to take over.
function onSubmit(event?: Event) {
  handleSubmit(
    (values) => {
      submitted.value = removeNullValues(values);
    },
    () => {},
  )(event);
}

For a production-scale wizard that layers conditional fields, dynamic options, repeatable steps, and a choice-based branch on top of these basics, see the Client Onboarding Wizard.

Released under the MIT License.