npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

react-wizard-engine

npm version bundle size downloads types license issues

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: Open in StackBlitz Open in CodeSandbox

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/shadcn for 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 a Promise<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-dialog

Quickstart

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 fields tree / steps / lastActiveStep / shapeId. All control methods return Promise<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:

GitHub Sponsors monobank Jar

Even a one-time tip is appreciated — it pays for issue triage, version bumps, and the next feature.

License

MIT (c) Nazarii Kovtun