react-wizard-engine
v0.1.19
Published
Headless multi-step wizard engine for React with categories, branches, pluggable strategies, async navigation, and a shadcn-compatible styled adapter.
Maintainers
Readme
react-wizard-engine
Headless multi-step wizard engine for React with categories, branches, pluggable strategies, async navigation, and a shadcn-compatible styled adapter. Drop into onboarding, checkout, signup, KYC, or any flow that needs more than a linear stepper.
Live demo: react-wizard-engine.vercel.app — 5 routes covering basic setup, every-option-preset, custom initializer, plain linear, and branch fork (diamond).
Fork the example in your browser:
The example source lives in the public react-wizard-engine-issues repo and consumes this package from npm.
Issues & feedback: Bug reports, feature requests, and questions are welcome at the public issues tracker. The library source is closed, but the issue board is open.
Features
- Headless core + opinionated styled adapter. Use just the engine, or import
react-wizard-engine/shadcnfor ready-made Radix + Tailwind UI matching shadcn defaults. - Steps · Categories · Branches. Compose flows that fan out (diamond), short-circuit (skip categories), or loop based on user choices.
- Pluggable strategies. Swap navigation, visibility, progress, and scroll behavior via
composeWizardProviders(withScrollStrategy(...))etc. - Async navigation with cancellation.
wizard.next()returns aPromise<boolean>; in-flight transitions auto-cancel on a newer call. - Resolver hooks. Gate step / category transitions with sync or async predicates (
resolveCategoryChange,resolveStepChange, etc.). - Dual ESM + CJS, full TypeScript types. Tree-shakeable,
sideEffects: false, type-checked end-to-end including step ID generics. - React 18+ compatible. Works with React 18 and React 19. Includes
'use client'directives for Next.js App Router.
Install
pnpm add react-wizard-engine lucide-react @radix-ui/react-slot
# only if you also use the styled /shadcn adapter:
pnpm add @radix-ui/react-dialogQuickstart
import {
WizardProvider,
WizardCategory,
WizardStep,
WizardBack,
WizardNext,
} from 'react-wizard-engine';
const initial = [
{ id: 'name', categoryId: 'profile', htmlIndex: 0, branches: ['main'], isActive: true, isShow: true, isCompleted: false, isSkipped: false },
{ id: 'email', categoryId: 'profile', htmlIndex: 1, branches: ['main'], isActive: false, isShow: true, isCompleted: false, isSkipped: false },
{ id: 'done', categoryId: 'confirm', htmlIndex: 2, branches: ['main'], isActive: false, isShow: true, isCompleted: false, isSkipped: false },
];
export function SignupWizard() {
return (
<WizardProvider state={initial}>
<WizardCategory categoryId="profile">
<WizardStep id="name">...name form...</WizardStep>
<WizardStep id="email">...email form...</WizardStep>
</WizardCategory>
<WizardCategory categoryId="confirm">
<WizardStep id="done">...review...</WizardStep>
</WizardCategory>
<WizardBack />
<WizardNext />
</WizardProvider>
);
}Examples
The live demo covers five patterns. Each is shown below.
import { composeWizardProviders, withConfig, WizardCategory, WizardProvider, WizardStep } from 'react-wizard-engine';
type Category = 'A' | 'B' | 'C';
type Step = 'A1' | 'A2' | 'A3' | 'B1' | 'B2' | 'C1' | 'C2';
const initial: IWizardStepState<Step, Category>[] = [
{ id: 'A1', categoryId: 'A', htmlIndex: 0, branches: ['main'], isActive: true, isShow: true, isCompleted: false, isSkipped: false },
// ...A2, A3, B1, B2, C1, C2 with isActive: false
];
export function SetupWizard() {
return (
<WizardProvider<Step, Category>
state={initial}
{...composeWizardProviders<Step, Category>(
withConfig({
doneDotText: 'Finish',
headerI18n: { A: 'Profile', B: 'Verify', C: 'Done' },
})
)}
>
<WizardCategory<Category> categoryId="A">{/* steps */}</WizardCategory>
<WizardCategory<Category> categoryId="B">{/* steps */}</WizardCategory>
<WizardCategory<Category> categoryId="C">{/* steps */}</WizardCategory>
</WizardProvider>
);
}// Useful when restoring a wizard mid-flow from persisted state.
// Note: within a category, marking step N+1 active also requires step N to be active —
// otherwise prev() from N+1 would deactivate the only active step and trip WizardActiveStepRule.
const initial: IWizardStepState<Step, Category>[] = [
{ id: 'A1', categoryId: 'A', htmlIndex: 0, branches: ['main'], isActive: false, isShow: true, isCompleted: true, isSkipped: false },
{ id: 'A2', categoryId: 'A', htmlIndex: 1, branches: ['main'], isActive: false, isShow: true, isCompleted: true, isSkipped: false },
{ id: 'A3', categoryId: 'A', htmlIndex: 2, branches: ['main'], isActive: false, isShow: false, isCompleted: true, isSkipped: false }, // hidden
{ id: 'B1', categoryId: 'B', htmlIndex: 3, branches: ['main'], isActive: false, isShow: true, isCompleted: false, isSkipped: true }, // skipped
{ id: 'B2', categoryId: 'B', htmlIndex: 4, branches: ['main'], isActive: false, isShow: true, isCompleted: false, isSkipped: true },
{ id: 'C1', categoryId: 'C', htmlIndex: 5, branches: ['main'], isActive: true, isShow: true, isCompleted: true, isSkipped: false }, // active + completed
{ id: 'C2', categoryId: 'C', htmlIndex: 6, branches: ['main'], isActive: true, isShow: true, isCompleted: false, isSkipped: false },
];import {
composeWizardProviders,
type IWizardTreeState,
WizardInitializer,
WizardProvider,
WizardTreeStateBuilder,
withInitializer,
} from 'react-wizard-engine';
class MyInitializer extends WizardInitializer<Step, Category> {
public override getState(
_tree: Readonly<IWizardTreeState<Step, Category>>,
htmlTree: Readonly<IWizardTreeState<Step, Category>>
) {
return new WizardTreeStateBuilder<Step, Category>(htmlTree)
.hideStep('A3')
.completeCategory('A')
.skipCategory('B')
.completeCategory('C')
.activateCategory('C')
.build();
}
}
<WizardProvider<Step, Category>
state={initial}
{...composeWizardProviders<Step, Category>(withInitializer(new MyInitializer()))}
>
{/* ... */}
</WizardProvider>import { useWizardEvent, WizardCategory, WizardEventType, WizardProvider, WizardStep } from 'react-wizard-engine';
import { useNavigate } from 'react-router-dom';
function ExitNavigator() {
const navigate = useNavigate();
useWizardEvent(WizardEventType.Exit, () => navigate('/'));
useWizardEvent(WizardEventType.Complete, () => navigate('/'));
return null;
}
<WizardProvider<Step, Category> state={initial}>
<ExitNavigator />
{/* WizardCategory + WizardStep tree, no WizardHeader */}
</WizardProvider>// (Root)
// o
// / \
// (1) o o (2)
// \ /
// o
// (End)
type Branch = 'D1' | 'D2';
type Category = 'Root' | 'A' | 'B' | 'End';
const initial = buildState(); // steps tagged with branches: ['D1', 'D2'] or ['D1'] or ['D2']
function BranchPicker() {
const wizard = useWizard<Step, Category, Branch>();
return (
<>
<button onClick={() => void wizard.next('D1')}>Go to D1</button>
<button onClick={() => void wizard.next('D2')}>Go to D2</button>
</>
);
}
<WizardProvider<Step, Category, Branch>
activeBranch="D1"
state={initial}
{...composeWizardProviders<Step, Category, Branch>(
withConfig({ headerI18n: { Root: 'Root', A: 'A', B: 'B', End: 'End' } })
)}
>
{/* Root / A / B / End categories. Steps in A live on branch D1, in B on branch D2,
Root and End on both. Calling wizard.next('D2') from R2 picks the D2 path. */}
</WizardProvider>Styled adapter
The /shadcn subpath ships an opinionated styled WizardTopBar and WizardLayout built on Radix + Tailwind utility classes that match shadcn defaults.
import { WizardLayout, WizardTopBar, WizardComponentsProviderWithDefaults } from 'react-wizard-engine/shadcn';
<WizardComponentsProviderWithDefaults>
<WizardProvider state={initial}>
<WizardLayout
topBar={<WizardTopBar onClose={onClose} />}
rail={<MyRail />}
>
{/* steps */}
</WizardLayout>
</WizardProvider>
</WizardComponentsProviderWithDefaults>To use your own button:
import { WizardComponentsProvider } from 'react-wizard-engine';
import { Button } from '@/components/ui/button';
<WizardComponentsProvider Button={Button}>
<WizardProvider state={initial}>{}</WizardProvider>
</WizardComponentsProvider>Concepts
- Step — one screen the user sees.
id,categoryId,htmlIndex(declaration order),branches[], plus four flags (isActive/isShow/isCompleted/isSkipped). - Category — a group of steps sharing a header tab.
- Branch — a named path through categories.
Full type definitions ship with the package (.d.ts).
Hooks
useWizard()— engine:next,back,go,showStep,hideStep,goToCategory,nextCategory,backCategory,skipCategory,resetWizard,exitWizard,completeWizard, snapshot fieldstree/steps/lastActiveStep/shapeId. All control methods returnPromise<boolean>.useWizardStep(id)— subscribes to one step's slice; re-renders only when its flags flip.useWizardEvent('*' | type, handler)— subscribes to engine events.
Strategies (pluggable)
Compose providers to override strategies/config. Back, visibility, progress, and scroll strategies are all overridable. Each strategy class is exported by name from react-wizard-engine.
composeWizardProviders(
withConfig({ isBackResetCompleted: false }),
withScrollStrategy(new WizardSmoothScrollStrategy()),
)Available helpers: withConfig, withInitializer, withListener, withProgressStrategy, withScrollContainer, withScrollStrategy, withVisibilityStrategy.
Common patterns
Use the resolver hooks in withConfig:
withConfig({
resolveStepChange: async (navigation) => {
const fromStep = navigation.from.activeCategory.activeStep.id;
const isValid = await validateStep(fromStep);
return isValid; // false => emits WizardNavigationCancelledEvent('resolver')
},
resolveCategoryChange: async (navigation) => { /* category-boundary gate */ },
resolveCategoryComplete: async (navigation) => { /* only when leaving a category */ },
resolveCategoryEnter: async (navigation) => { /* every category boundary */ },
});Throwing emits WizardNavigationErrorEvent. Returning false (or rejecting the promise) emits WizardNavigationCancelledEvent('resolver'). A new next() call short-circuits an in-flight resolver — no stale resolutions.
Hook the WizardStorageListener into the provider via withListener(...):
import { WizardStorageListener, composeWizardProviders, withListener } from 'react-wizard-engine';
const myAdapter = {
read: (key) => /* return persisted state or null */,
write: (key, value) => /* persist */,
remove: (key) => /* delete */,
};
<WizardProvider state={initial} {...composeWizardProviders(
withListener(new WizardStorageListener(myAdapter, /* shapeId */ 'apply-v3', /* prefix */ 'wizard:')),
)}>The listener writes on every tree change and rehydrates on mount. shapeId invalidates stored state when your step schema changes.
Tag steps with the branches they belong to, and use wizard.next(branchId) to pick a path:
const initial = [
{ id: 'R1', categoryId: 'Root', branches: ['D1', 'D2'], /* ...*/ },
{ id: 'A1', categoryId: 'A', branches: ['D1'], /* ...*/ }, // only on D1 path
{ id: 'B1', categoryId: 'B', branches: ['D2'], /* ...*/ }, // only on D2 path
{ id: 'E1', categoryId: 'End', branches: ['D1', 'D2'], /* ...*/ }, // merge point
];
// Inside a step component, switch branch via `wizard.next(branchId)`:
const wizard = useWizard<Step, Category, Branch>();
<button onClick={() => void wizard.next('D2')}>Go to B</button>The wizard recomputes the active branch and prunes invisible steps.
Use useWizardEngineRef to keep a stable ref to the engine outside the provider's child tree, so the submit handler can navigate the wizard on field errors:
import { useWizardEngineRef, WizardEngineRefCapture, WizardProvider } from 'react-wizard-engine';
function MyForm() {
const form = useForm({ resolver: zodResolver(schema) });
const wizardRef = useWizardEngineRef<Step, Category>();
const onSubmit = form.handleSubmit(
async (values) => { /* happy path */ },
async () => {
const wizard = wizardRef.current;
if (!wizard) return;
// Navigate to the first step with an error field, focus it, etc.
const errored = Object.keys(form.formState.errors);
const target = (Object.keys(stepFields) as Step[]).find(id =>
stepFields[id].some(f => errored.includes(f))
);
if (target) await wizard.goToCategory(target as unknown as Category);
},
);
return (
<FormProvider {...form}>
<form onSubmit={onSubmit}>
<WizardProvider state={initial}>
<WizardEngineRefCapture engineRef={wizardRef} />
{/* ...steps */}
</WizardProvider>
</form>
</FormProvider>
);
}Each strategy is a class with a single method. Extend the base, register via the matching withXxxStrategy(...):
import { WizardScrollStrategy, composeWizardProviders, withScrollStrategy } from 'react-wizard-engine';
class NoopScrollStrategy extends WizardScrollStrategy {
public override scrollTo() { /* do nothing */ }
}
<WizardProvider state={initial} {...composeWizardProviders(
withScrollStrategy(new NoopScrollStrategy()),
)}>The same pattern applies to WizardBackStrategy, WizardVisibilityStrategy, WizardProgressStrategy.
Resolvers can return promises:
withConfig({
resolveStepChange: async (navigation) => {
if (navigation.direction !== 'next') return true; // only gate forward
try {
await schema.parseAsync(stepValues); // zod async refine, server check, etc.
return true;
} catch {
return false; // cancel navigation; emits WizardNavigationCancelledEvent('resolver')
}
},
});If a newer next() lands while this resolver is still running, the engine cancels the stale resolution automatically.
Support
If react-wizard-engine saves you time, consider sponsoring continued maintenance:
Even a one-time tip is appreciated — it pays for issue triage, version bumps, and the next feature.
License
MIT (c) Nazarii Kovtun
