@wizzard-packages/vue

Vue 3 composition API and components for Wizzard Stepper.
5,012 1
Install
npm install @wizzard-packages/vue
Latest Version:0.2.2
Vue:3
License:MIT
Last Updated:Oct 20, 2026
Links: GitHub  ·  npm
Maintainer: zizzxiii

wizzard

npm license

A multi-step flow is a graph, but most libraries make you write it as a list and keep the branches in your components. Here the flow is data — a plain JSON object — and one engine runs it for React and Vue alike. Because it is data, you can draw it, send it from a server, diff it, and replay a recorded run of it.

The documentation is at zizzx.github.io/wizzard-packages, and Getting started is the page to read first.

pnpm add @wizzard-packages/core@canary @wizzard-packages/react@canary

A wizard in twenty lines

Two steps, one field, and a value that is still there after Back.

import { defineFlow, step } from '@wizzard-packages/core';

/**
 * The smallest flow that is still a wizard: two steps, one field, and a value
 * that has to survive going back.
 *
 * A flow is data. This object is JSON — no functions, no classes — so the same
 * definition can come from a file, from a backend, or from a generator, and one
 * engine runs all three.
 */
export const signup = defineFlow({
  id: 'signup',
  order: ['name', 'review'],
  steps: {
    name: step<{ full: string }>({ label: 'Your name' }),
    review: step({ label: 'Review' }),
  },
});
import { WizardProvider, useField, useNavigation, useStep } from '@wizzard-packages/react';

import { signup } from './flow';

export function App() {
  return (
    <WizardProvider flow={signup}>
      <Wizard />
    </WizardProvider>
  );
}

export function Wizard() {
  const { current, isLast } = useStep();
  const { next, back, canBack } = useNavigation();
  const [full, setFull] = useField<string>('name.full');

  // Nothing is current until the engine starts, which happens in the browser:
  // on the server, and for the first paint, `current` is null. So the form
  // draws the step it is about to enter and keeps every control out of reach
  // until the engine can act on it - the field included, because anything typed
  // before the engine exists is not in its state and the first commit would
  // wipe it.
  const step = current ?? 'name';
  const starting = current === null;

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      {step === 'name' && (
        <label>
          Your name
          <input value={full ?? ''} onChange={(e) => setFull(e.target.value)} disabled={starting} />
        </label>
      )}
      {step === 'review' && <p>Hello, {full || 'stranger'}.</p>}

      <button type="button" onClick={() => back()} disabled={starting || !canBack}>
        Back
      </button>
      <button type="button" onClick={() => next()} disabled={starting || isLast}>
        Next
      </button>
    </form>
  );
}

Vue is the same engine and the same hook names. The component that provides the wizard is not the one that uses it, because Vue's inject reads the parent chain:

<script setup lang="ts">
import { provideWizard } from '@wizzard-packages/vue';

import Wizard from './Wizard.vue';
import { signup } from './flow';

// The wizard is provided here and consumed by the child. Vue's inject reads the
// parent chain, so the component that provides cannot also use the composables.
provideWizard({ flow: signup });
</script>

<template>
  <Wizard />
</template>

The child is where the composables are called:

<script setup lang="ts">
import { useField, useNavigation, useStep } from '@wizzard-packages/vue';
import { computed } from 'vue';

const { current, isLast } = useStep();
const { next, back, canBack } = useNavigation();
const full = useField<string>('name.full');

// Nothing is current until the engine starts, which happens in the browser: on
// the server, and for the first paint, `current` is null. So the form draws the
// step it is about to enter and keeps every control out of reach until the
// engine can act on it - the field included, because anything typed before the
// engine exists is not in its state and the first commit would wipe it.
const step = computed(() => current.value ?? 'name');
const starting = computed(() => current.value === null);
</script>

<template>
  <form @submit.prevent>
    <label v-if="step === 'name'">
      Your name
      <input v-model="full" :disabled="starting" />
    </label>
    <p v-else-if="step === 'review'">Hello, {{ full || 'stranger' }}.</p>

    <button type="button" :disabled="starting || !canBack" @click="back()">Back</button>
    <button type="button" :disabled="starting || isLast" @click="next()">Next</button>
  </form>
</template>

The files above are examples/quickstart. CI runs them on both bindings and fails if this README drifts from them, so what you paste is what is tested.

Groups and repeat

A repeated section — one block of steps per passenger — is a sub-flow entered once per item. The traversal that walks it is a separate entry, so a flat flow never carries it:

import { createWizard } from '@wizzard-packages/core';
import { groups } from '@wizzard-packages/core/groups';

const passenger = {
  id: 'passenger',
  order: ['seat', 'meal'],
  steps: { seat: {}, meal: {} },
};

const trip = {
  id: 'trip',
  version: 1,
  order: ['passengers', 'review'],
  steps: {
    passengers: {
      flow: 'passenger',
      when: { $not: { $empty: { $get: 'data.passengers' } } },
      repeat: { over: { $get: 'data.passengers' }, keyBy: 'id' },
    },
    review: {},
  },
};

const wizard = createWizard({ flow: trip, groups, subFlows: { passenger } });

keyBy names the field that identifies an item. The stack stores that key and nothing else, so reordering the list moves the person with their item and removing one they are not on leaves them where they are. Inside the sub-flow, { $get: 'loop.item' }, loop.index and loop.key are in scope for guards, conditions and validators. Groups, repeat and sub-flows is the guide; docs/designs/group-traversal.md is the full set of rules. What you can build puts this beside a branch, a loading step, a validator and a restored session, in one definition and one run of it, and Concepts and glossary defines each word the documentation uses. The Tutorial builds a wizard with all of them one change at a time, starting from two steps.

Why this and not something else

No stale async transitions. Validation, guards and loading are eleven phases of one pipeline, and state is written exactly once at the end of it. Every await re-checks a navigation epoch, so a slow validator that resolves after you pressed Back cannot move you. Navigation returns a result — { ok: false, reason: 'blocked', by: 'age-check', code: 'nav-blocked', url }, where url is the page that explains the refusal — never a bare boolean.

One engine, two bindings. @wizzard-packages/react is 1.04 kB and @wizzard-packages/vue is 732 B, against 8.48 kB and 5.07 kB for their 0.x equivalents. Nothing was optimised to get there: the logic moved into the engine. A shared contract suite runs against both, which is what stops them drifting apart.

The flow is JSON. No functions, no classes, no Sets — in the definition or in the state. Predicates and validators are named entries in a registry, so JSON.stringify(flow) always round-trips. That is what makes a graph view, a server-driven flow and a replayable session possible at all.

When it earns its dependency

Reach for @stepperize/react if you need a stepper: one hook, one list of steps, and your own branching in components. It is smaller and simpler, and for a linear form it is the right answer.

Reach for XState if your problem is a state machine that happens to have a UI — parallel states, actors, invoked services. It is a bigger idea than a wizard, and its visualiser is excellent.

Reach for this when the flow itself is the thing you need to hold: branches that depend on answers, a back button that must not lose data, a reload that must not lose progress, or a definition that comes from somewhere other than your bundle. When a useReducer and three useEffects have started disagreeing about which step you are on, that is the moment.

Packages

Package What it is gzip
@wizzard-packages/core the engine: flow types, expressions, navigation, selectors 5.13 kB
@wizzard-packages/react provider and hooks 1.04 kB
@wizzard-packages/vue provideWizard and the same composables 732 B
@wizzard-packages/validate one adapter for Zod, Valibot, ArkType, Effect and Yup 317 B
@wizzard-packages/core/graph a flow as { nodes, edges }, for drawing it 754 B

Separate entries because they are separate budgets: a wizard that never draws itself does not carry the code that would.

Every export of every entry, with its signature, is in the API reference, generated from the source on each build of the site.

Supported

Node 20.11+, TypeScript 5+, React 18+, Vue 3.5+. ESM and CJS, types for both.

Status

v1 is on the canary tag while the launch lands. The engine, both bindings, validation, persistence and devtools are done, and the documentation site is up. docs/designs/v1-launch.md is the plan, and ROADMAP.md is where it came from. The 0.x line on latest is a different library with the same name and is being retired — docs/MIGRATION.md is the way across. Its code is gone from this repository, and @wizzard-packages/core, /react and /vue now import the v1 engine. The /v1 subpath still resolves, to the same module and the same types as the root, and stays through 1.x, so an import written against canary keeps working.

License

MIT © ZizzX

Related Packages

@daltonr/pathwrite-vue

Vue 3 adapter for @daltonr/pathwrite-core — composables with reactive refs, and...

3,961 5
form-wizard-vue3

Form Wizard Vue 3 is a simple stepper/wizard component. It can be easily customi...

38,389 30
@doderasoftware/vue-stepper

A headless, accessible, and fully customizable multi-step stepper component for...

1,930 0
@vue/composition-api

Provide logic composition capabilities for Vue.

6,521,705 4,179
@twentyfourg/grimoire

A modern, headless Vue 3 component library built with TypeScript and Composition...

7,265

Version History

Version Released License
0.2.2 MIT
0.2.1 MIT
0.2.0 MIT
Pre-releases (46)
Version Released License
1.0.0-canary-20261020084613 MIT
1.0.0-canary-20261016121057 MIT
1.0.0-canary-20261016111800 MIT
1.0.0-canary-20261016072627 MIT
1.0.0-canary-20261016061842 MIT