@wizzard-packages/vue
wizzard
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
Vue 3 adapter for @daltonr/pathwrite-core — composables with reactive refs, and...
Form Wizard Vue 3 is a simple stepper/wizard component. It can be easily customi...
A headless, accessible, and fully customizable multi-step stepper component for...
A modern, headless Vue 3 component library built with TypeScript and Composition...
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 | |
| 1.0.0-canary-20261014203313 | MIT | |
| 1.0.0-canary-20261014201339 | MIT | |
| 1.0.0-canary-20261014195859 | MIT | |
| 1.0.0-canary-20261014200023 | MIT | |
| 1.0.0-canary-20261014193723 | MIT | |
| 1.0.0-canary-20261014183133 | MIT | |
| 1.0.0-canary-20261014182315 | MIT | |
| 1.0.0-canary-20261014174914 | MIT | |
| 1.0.0-canary-20261012121815 | MIT | |
| 1.0.0-canary-20261008101851 | MIT | |
| 1.0.0-canary-20261008065952 | MIT | |
| 1.0.0-canary-20261008064145 | MIT | |
| 1.0.0-canary-20261008050745 | MIT | |
| 1.0.0-canary-20261007135002 | MIT | |
| 1.0.0-canary-20261007134415 | MIT | |
| 1.0.0-canary-20261006065916 | MIT | |
| 1.0.0-canary-20261006053553 | MIT | |
| 1.0.0-canary-20261006050756 | MIT | |
| 1.0.0-canary-20261005124129 | MIT | |
| 1.0.0-canary-20261005114307 | MIT | |
| 1.0.0-canary-20261005110907 | MIT | |
| 1.0.0-canary-20261005110324 | MIT | |
| 1.0.0-canary-20261005104434 | MIT | |
| 1.0.0-canary-20261005103409 | MIT | |
| 1.0.0-canary-20261005102811 | MIT |
Showing the latest 30 of 46. See every release on npm