Your flow logic,
independent of everything.

Most wizard libraries bury your business logic inside UI components. Pathwrite separates them: your flow is a plain TypeScript object you can develop, test, version, and share — completely independently of any framework.

npm install @daltonr/pathwrite-core
Live Pathwrite Engine — Not a Mockup steps
Loading…
Real engine from npm — reload the browser at any time, your progress restores. How? →
810

Tests — and zero framework dependencies

Every guard, hook, and navigation path is tested in pure Node. The engine runs identically in a browser, a test runner, and React Native.

6

First-class framework adapters

React, Vue, Angular, Svelte, SolidJS, and React Native. Each adapter provides an idiomatic hook, composable, or injectable — plus an optional PathShell UI.

1

Flow logic that belongs to no framework

Your PathDefinition is a plain TypeScript object. Package it as an npm module, version it independently, and import it into every app — web and mobile.

A flow that stands on its own

A PathDefinition is a plain TypeScript object — no framework imports, no component coupling, no DOM. Because it has no dependencies, you get four things that are impossible when logic lives inside components.

Develop independently
Your guards, conditional steps, and lifecycle hooks live in their own package. UI engineers and flow engineers work without stepping on each other.
Test without a browser
Every navigation path, guard, and validation rule is testable with new PathEngine(). No DOM. No component mounting. No Playwright.
Version independently
Ship your flow as an npm package with its own semver. Business logic changes don't force UI releases. Consumers upgrade on their schedule.
Use in any framework
The same package imports into React, Vue, Angular, Svelte, SolidJS, and React Native. One source of truth for your checkout, onboarding, or application flow.

How Pathwrite compares

No other package combines all of these.

Pathwrite XState react-step-wizard Formik / RHF
Framework-agnostic core
React + Vue + Angular + Svelte + Solid2 of 5React onlyReact only
React Native (iOS & Android)
Built-in persistence
Navigation guards
Field-level validation
Conditional step skipping
Sub-paths / nested flows
Optional shell UI
Testable without UI
Wizard-specific API
Learning curveMinutesDaysMinutesHours
@daltonr/pathwrite-react-native

The same engine. iOS and Android.

Pathwrite has a first-class React Native adapter. The same PathDefinition that powers your web checkout runs on iOS and Android too — guards, sub-paths, persistence, and all.

PathShell adapts to native layout automatically. Or go headless with usePath() and build your own native UI.

The demo to the right is a real React Native app compiled to web via Expo. It runs the same engine that powers all the web demos.

How this works →

Define once. Test without a browser. Ship to every framework.

The same job-application flow — from pure TypeScript to headless tests to React, Vue, Angular, Svelte, SolidJS, and React Native.

my-app-workflows/src/job-application.ts
// Pure TypeScript — no React, no Vue, no Angular.
// This file is the entire workflow. Ship it as its own npm package.
import type { PathDefinition } from "@daltonr/pathwrite-core";

export interface ApplicationServices {
  checkEligibility(years: number): Promise<{ eligible: boolean; reason?: string }>;
  requiresCoverLetter(roleId: string): Promise<boolean>;
}

export function createApplicationPath(svc: ApplicationServices) {
  return {
    id: "job-application",
    steps: [
      { id: "role",         title: "Role",         canMoveNext: ({ data }) => !!data.roleId },
      { id: "experience",    title: "Experience" },
      {
        id: "eligibility",  title: "Eligibility",
        canMoveNext: async ({ data }) => {
          const r = await svc.checkEligibility(Number(data.yearsExperience));
          return r.eligible ? true : { allowed: false, reason: r.reason! };
        },
      },
      {
        id: "cover-letter",  title: "Cover Letter",
        shouldSkip: async ({ data }) => !(await svc.requiresCoverLetter(data.roleId)),
      },
      { id: "review",        title: "Review" },
    ],
  } satisfies PathDefinition<ApplicationData>;
}

Guards, async conditions, and lifecycle hooks — all plain TypeScript. Zero UI coupling.

my-app-workflows/test/job-application.test.ts
// No framework. No DOM. No component mounting.
// Every guard, route, and conditional step is testable as pure logic.
import { PathEngine } from "@daltonr/pathwrite-core";
// relative import — these tests live inside the my-app-workflows package itself
import { createApplicationPath } from "../src/job-application";

const fast = {
  checkEligibility: async (y) => y >= 2 ? { eligible: true } : { eligible: false, reason: "Min 2 years required" },
  requiresCoverLetter: async (id) => id === "eng" || id === "data",
};

it("blocks navigation when experience < 2 years", async () => {
  const engine = new PathEngine();
  await engine.start(createApplicationPath(fast), { roleId: "eng", yearsExperience: "1" });
  await engine.next(); // → experience
  await engine.next(); // → eligibility
  await engine.next(); // guard blocks — stays on eligibility
  expect(engine.snapshot()?.stepId).toBe("eligibility");
  expect(engine.snapshot()?.blockingError).toBeTruthy();
});

it("skips cover letter for non-engineering roles", async () => {
  const engine = new PathEngine();
  await engine.start(createApplicationPath(fast), { roleId: "pm", yearsExperience: "5" });
  await engine.next(); await engine.next(); await engine.next();
  expect(engine.snapshot()?.stepId).toBe("review"); // cover-letter was skipped
});

CI runs these in milliseconds. No browser, no Playwright, no mounted components.

my-react-app/JobApplication.tsx
import { PathShell } from "@daltonr/pathwrite-react";
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { apiServices } from "./services";

export default function JobApplication() {
  return (
    <PathShell
      path={createApplicationPath(apiServices)}
      initialData={{ roleId: "", yearsExperience: "" }}
      onComplete={handleSubmit}
      steps={{
        role:           <RoleStep />,
        experience:     <ExperienceStep />,
        eligibility:    <EligibilityStep />,
        "cover-letter": <CoverLetterStep />,
        review:         <ReviewStep />,
      }}
    />
  );
}

The workflow logic stays in my-app-workflows. This file is UI only.

my-vue-app/JobApplication.vue
<script setup>
import { PathShell } from "@daltonr/pathwrite-vue";
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { apiServices } from "./services";
const path = createApplicationPath(apiServices);
</script>

<template>
  <PathShell :path="path" :initial-data="{ roleId: '', yearsExperience: '' }"
             @complete="handleSubmit">
    <template #role><RoleStep /></template>
    <template #experience><ExperienceStep /></template>
    <template #eligibility><EligibilityStep /></template>
    <template #cover-letter><CoverLetterStep /></template>
    <template #review><ReviewStep /></template>
  </PathShell>
</template>

Same workflow package import. Vue-idiomatic slot syntax replaces JSX — the business logic is unchanged.

my-ng-app/job-application.component.ts + .html
// job-application.component.ts
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { ApiServices } from "./api.service";

export class JobApplicationComponent {
  path = createApplicationPath(inject(ApiServices));
}

// job-application.component.html
<pw-shell [path]="path" [initialData]="{ roleId: '', yearsExperience: '' }"
          (completed)="handleSubmit($event)">
  <ng-template pwStep="role"><app-role-step /></ng-template>
  <ng-template pwStep="experience"><app-experience-step /></ng-template>
  <ng-template pwStep="eligibility"><app-eligibility-step /></ng-template>
  <ng-template pwStep="cover-letter"><app-cover-letter-step /></ng-template>
  <ng-template pwStep="review"><app-review-step /></ng-template>
</pw-shell>

Angular DI injects real services at runtime. The workflow package never imports Angular.

my-svelte-app/JobApplication.svelte
<script>
  import { PathShell } from "@daltonr/pathwrite-svelte";
  import { createApplicationPath } from "my-app-workflows"; // ← shared package
  import { apiServices } from "./services";
  const path = createApplicationPath(apiServices);
</script>

<PathShell {path} initialData={{ roleId: "", yearsExperience: "" }}
           oncomplete={handleSubmit}>
  {#snippet role()}<RoleStep />{/snippet}
  {#snippet experience()}<ExperienceStep />{/snippet}
  {#snippet eligibility()}<EligibilityStep />{/snippet}
  {#snippet coverLetter()}<CoverLetterStep />{/snippet}
  {#snippet review()}<ReviewStep />{/snippet}
</PathShell>

Svelte 5 snippets replace JSX. The workflow import is identical to every other framework.

my-solid-app/JobApplication.tsx
import { PathShell } from "@daltonr/pathwrite-solid";
import { createApplicationPath } from "my-app-workflows"; // ← shared package
import { apiServices } from "./services";

export default function JobApplication() {
  return (
    <PathShell
      path={createApplicationPath(apiServices)}
      initialData={{ roleId: "", yearsExperience: "" }}
      onComplete={handleSubmit}
      steps={{
        role:           () => <RoleStep />,
        experience:     () => <ExperienceStep />,
        eligibility:    () => <EligibilityStep />,
        "cover-letter": () => <CoverLetterStep />,
        review:         () => <ReviewStep />,
      }}
    />
  );
}

Same workflow package import. Steps take render functions — Solid's fine-grained reactivity handles updates without re-rendering the whole component.

my-rn-app/JobApplication.tsx
// The only difference from the React web version is this import.
import { PathShell } from "@daltonr/pathwrite-react-native";
import { createApplicationPath } from "my-app-workflows"; // ← same shared package
import { apiServices } from "./services";

export default function JobApplication() {
  return (
    <PathShell
      path={createApplicationPath(apiServices)}
      initialData={{ roleId: "", yearsExperience: "" }}
      onComplete={handleSubmit}
      steps={{
        role:           <RoleStep />,
        experience:     <ExperienceStep />,
        eligibility:    <EligibilityStep />,
        "cover-letter": <CoverLetterStep />,
        review:         <ReviewStep />,
      }}
    />
  );
}

Same workflow, same tests, iOS and Android. See it running live below.

Live demos

Every demo runs the same workflow package across all adapters.

Linear Wizard

Guards, per-field validation, lifecycle hooks, and a review step.

Conditional Steps

Steps dynamically skipped based on user choices. Stepper updates automatically.

Sub-Path (Nested)

Nested sub-wizards that auto-resume the parent on completion.

Single-Step Form

Contact form with field validation, error timing, and form-mode layout.

StepChoice (Dynamic Routing)

Steps that branch at runtime — show different sub-steps based on user data.

Persistence & Sessions

LocalStorage persistence with auto-save, multi-session management, and progress restore.

Documentation

View all documentation →

Stop building wizard infrastructure.

Install Pathwrite and ship your first flow today.

npm install @daltonr/pathwrite-core
@daltonr/pathwrite-react @daltonr/pathwrite-vue @daltonr/pathwrite-angular @daltonr/pathwrite-svelte @daltonr/pathwrite-solid @daltonr/pathwrite-react-native @daltonr/pathwrite-store