A universal, production-ready step-by-step form component (Wizard) built with React, TypeScript, and XState. This library provides a flexible and powerful solution for creating multi-step forms with state management, validation, conditional steps, and smooth animations.
- 🎯 State Management: Powered by XState 5 for robust state management
- ✅ Validation: Built-in support for Zod schema validation and custom validation functions
- 🔄 Conditional Steps: Dynamically show/hide steps based on form data
- 📱 Responsive Design: Fully responsive with mobile-optimized step indicator
- 🎨 Customizable: Easy to style and customize to match your design system
- 🧩 Type-Safe: Full TypeScript support with comprehensive type definitions
- 🧪 Well-Tested: Comprehensive test coverage with Vitest
- 🏗️ FSD Architecture: Organized using Feature-Sliced Design methodology
npm install# Development server
npm run dev
# Build for production
npm run build
# Run tests
npm test
# Run tests with coverage
npm run test:coverage
# Lint code
npm run lint
# Format code
npm run formatThis project uses Husky to run checks before each commit:
- Lint-staged: Automatically lints and formats staged files (
.ts,.tsx,.js,.jsx,.json,.css,.md) - Tests: Runs all tests before allowing the commit
If any check fails, the commit will be blocked. This ensures code quality and prevents broken code from being committed.
To bypass hooks (not recommended), use git commit --no-verify.
This project is configured for automatic deployment to GitHub Pages using GitHub Actions.
-
Update the base path in
vite.config.ts:- Replace
'/react-stepflow/'with your repository name - For example, if your repo is
my-wizard-app, change it to'/my-wizard-app/' - If deploying from the root of your GitHub Pages site, use
'/'
- Replace
-
Enable GitHub Pages in your repository:
- Go to your repository Settings → Pages
- Under "Source", select "GitHub Actions"
-
Push to the main branch:
- The workflow will automatically build and deploy your site
- Your site will be available at:
https://<your-username>.github.io/<repository-name>/
If you prefer to deploy manually:
# Build the project
npm run build
# The dist folder will contain the production build
# You can deploy it using any static hosting serviceTo preview the production build locally:
npm run build
npm run previewThe project follows Feature-Sliced Design (FSD) architecture:
src/
├── app/ # Application layer (example usage)
├── widgets/ # Complex UI blocks
│ └── wizard/ # Wizard component library
├── shared/ # Shared resources
│ ├── lib/ # Shared libraries (XState machine, context)
│ └── ui/ # Shared UI components (Input, Checkbox, etc.)
└── test/ # Test files
The main wrapper component that manages form steps and state using XState.
Props:
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Array of <Step /> components |
onFinish |
(data: Record<string, any>) => void |
Callback function called after the last step |
initialData? |
Record<string, any> |
Initial form state |
debug? |
boolean |
Enable XState state logging to console |
Example:
<Wizard
onFinish={(data) => {
console.log('Form submitted:', data);
// Send to server
}}
initialData={{ name: 'John' }}
>
<Step title="Step 1">...</Step>
<Step title="Step 2">...</Step>
</Wizard>Wrapper component for a single form step with built-in validation and navigation.
Props:
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Step content |
title? |
string |
Step title (optional) |
validate? |
(data: Record<string, any>) => boolean | string[] |
Custom validation function |
schema? |
z.ZodSchema<any> |
Zod schema for validation |
condition? |
(data: Record<string, any>) => boolean |
Conditional function for step display |
hideDefaultButtons? |
boolean |
Hide default Next/Back buttons |
customNextLabel? |
string |
Custom Next button text |
customSubmitLabel? |
string |
Custom Submit button text (on last step) |
Example:
<Step
title="Personal Information"
schema={z.object({
firstName: z.string().min(2),
email: z.string().email(),
})}
condition={(data) => data.userType === 'individual'}
>
<Input name="firstName" placeholder="First Name" />
<Input name="email" type="email" placeholder="Email" />
</Step>Visual step indicator component that displays progress, active step, and completed steps. Automatically included in <Wizard /> but can be customized.
Hook for accessing navigation functions and form data from components inside steps.
Returns:
{
goNext: () => void; // Navigate to next step
goBack: () => void; // Navigate to previous step
goToStep: (index: number) => void; // Navigate to specific step
updateData: (partialData: Partial<WizardData>) => void; // Update form data
finish: () => void; // Finish the form
reset: () => void; // Reset the form
currentStep: number; // Current step index (0-based)
totalSteps: number; // Total number of steps
data: WizardData; // All collected form data
isFirstStep: boolean; // Is this the first step
isLastStep: boolean; // Is this the last step
isFinished: boolean; // Is the form finished
}Example:
const MyComponent = () => {
const { goNext, goBack, currentStep, totalSteps, data } = useWizard();
return (
<div>
<p>
Step {currentStep + 1} of {totalSteps}
</p>
<button onClick={goBack}>Back</button>
<button onClick={goNext}>Next</button>
</div>
);
};Hook for working with controlled inputs inside steps. Automatically syncs with the global form state.
Returns:
{
values: Record<string, any>; // Current field values
setValue: (name: string, value: any) => void; // Set field value
getValue: (name: string) => any; // Get field value
}Example:
const CustomInput = ({ name }) => {
const { values, setValue } = useWizardForm();
return <input value={values[name] || ''} onChange={(e) => setValue(name, e.target.value)} />;
};The library includes a set of pre-built form components that automatically integrate with the wizard:
Controlled input component that automatically syncs with form state.
<Input name="firstName" type="text" placeholder="Enter your first name" />Supported types: text, email, tel, number, date, url, etc.
Controlled textarea component.
<Textarea name="description" rows={4} placeholder="Enter description" />Controlled checkbox component. Supports multiple selections when using the same name.
<Checkbox name="agree">
I agree to the terms and conditions
</Checkbox>
<Checkbox name="services" value="cleaning">
Cleaning service
</Checkbox>Controlled radio button component.
<Radio name="clientType" value="individual">
Individual
</Radio>
<Radio name="clientType" value="business">
Business
</Radio>Controlled select dropdown component.
<Select
name="country"
options={[
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
]}
required
/>Special radio button component styled as a card, perfect for plan/tariff selection.
<TariffCard
name="tariff"
value="premium"
title="Premium Plan"
price="$99/month"
description="Best value for large teams"
features={['Feature 1', 'Feature 2', 'Feature 3']}
recommended
/>The library supports two types of validation:
Use Zod schemas for type-safe, declarative validation:
import { z } from 'zod';
const personalInfoSchema = z.object({
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Please enter a valid email address'),
phone: z.string().regex(/^[\d\s\-\+\(\)]+$/, {
message: 'Please enter a valid phone number',
}),
});
<Step title="Personal Information" schema={personalInfoSchema}>
<Input name="firstName" />
<Input name="lastName" />
<Input name="email" type="email" />
<Input name="phone" type="tel" />
</Step>;For more complex validation logic:
<Step
title="Step 1"
validate={(data) => {
const errors: string[] = [];
if (!data.name) {
errors.push('Name is required');
}
if (data.age && data.age < 18) {
errors.push('You must be at least 18 years old');
}
return errors.length > 0 ? errors : true;
}}
>
<Input name="name" />
<Input name="age" type="number" />
</Step>Validation Return Types:
true- Validation passedfalse- Validation failed (generic error)string[]- Array of error messages
If validation fails, navigation is blocked and errors are displayed below the step content.
Steps can be conditionally shown or hidden based on form data:
<Step
title="Individual Information"
condition={(data) => data.clientType === 'individual'}
>
<Input name="firstName" />
<Input name="lastName" />
</Step>
<Step
title="Business Information"
condition={(data) => data.clientType === 'business'}
>
<Input name="companyName" />
<Input name="taxId" />
</Step>The wizard automatically adjusts the total number of steps and navigation when steps are conditionally shown/hidden.
import React from 'react';
import { z } from 'zod';
import { Wizard, Step, useWizard } from '@/widgets/wizard';
import { Input, Checkbox, Radio, Select, TariffCard } from '@/shared/ui';
// Validation schemas
const personalInfoSchema = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
});
const agreementSchema = z.object({
agree: z.boolean().refine((val) => val === true, {
message: 'You must agree to the terms',
}),
});
function App() {
const handleFinish = (data: Record<string, any>) => {
console.log('Form submitted:', data);
// Send to server
fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<Wizard
onFinish={handleFinish}
initialData={{
clientType: 'individual',
tariff: 'standard',
}}
>
<Step title="Welcome" customNextLabel="Get Started">
<div>
<h2>Welcome to our service!</h2>
<p>Let's get started with a few simple steps.</p>
</div>
</Step>
<Step title="Client Type">
<Radio name="clientType" value="individual">
Individual
</Radio>
<Radio name="clientType" value="business">
Business
</Radio>
</Step>
<Step
title="Personal Information"
condition={(data) => data.clientType === 'individual'}
schema={personalInfoSchema}
>
<Input name="firstName" placeholder="First Name" />
<Input name="lastName" placeholder="Last Name" />
<Input name="email" type="email" placeholder="Email" />
</Step>
<Step
title="Choose Plan"
schema={z.object({
tariff: z.enum(['basic', 'standard', 'premium']),
})}
>
<TariffCard
name="tariff"
value="basic"
title="Basic"
price="$29/month"
features={['Feature 1', 'Feature 2']}
/>
<TariffCard
name="tariff"
value="standard"
title="Standard"
price="$49/month"
features={['Feature 1', 'Feature 2', 'Feature 3']}
recommended
/>
<TariffCard
name="tariff"
value="premium"
title="Premium"
price="$99/month"
features={['All features']}
/>
</Step>
<Step title="Agreement" schema={agreementSchema}>
<Checkbox name="agree">I agree to the terms and conditions</Checkbox>
</Step>
<Step title="Review">
<Summary />
</Step>
</Wizard>
);
}
const Summary = () => {
const { data } = useWizard();
return (
<div>
<h3>Review Your Information</h3>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
};The wizard uses XState 5 for state management. The state machine has the following states:
editing- Form is being edited (navigating through steps)submitting- Form is being submittedfinished- Form submission is complete
Events:
NEXT- Navigate to next stepPREV- Navigate to previous stepUPDATE_DATA- Update form dataUPDATE_TOTAL_STEPS- Update total number of steps (for conditional steps)SUBMIT- Submit the form
The Wizard component creates a WizardFormContext that allows any child component to get and set field values through the useWizardForm() hook. This context automatically syncs with the XState machine state.
The project is organized using Feature-Sliced Design methodology:
app/- Application layer (example usage, not part of the library)widgets/wizard/- Wizard component library (main export)shared/lib/- Shared libraries (XState machine, form context)shared/ui/- Shared UI components (Input, Checkbox, etc.)
The library components come with default styles that can be customized:
widgets/wizard/ui/wizard.css- Wizard container styleswidgets/wizard/ui/step.css- Step component styleswidgets/wizard/ui/step-indicator.css- Step indicator styles
All styles are automatically imported when using the components. You can override them in your application styles.
The step indicator automatically adapts to mobile screens:
- Horizontal layout on mobile
- Compact circles without labels
- Thin connecting lines
- Smooth scrolling
All components are fully typed with TypeScript. Main types are exported:
import type { WizardProps, StepProps, WizardData, ValidationResult } from '@/widgets/wizard';The project includes comprehensive tests using Vitest and React Testing Library:
# Run all tests
npm test
# Run tests in watch mode
npm test -- --watch
# Run tests with UI
npm run test:ui
# Run tests with coverage
npm run test:coverageTest files are located in src/test/ directory, maintaining the FSD structure.
The project uses ESLint and Prettier for code quality and formatting:
- ESLint: Linting with TypeScript, React, React Hooks, and accessibility rules
- Prettier: Automatic code formatting
- TypeScript: Type checking via
tsc
# Check for linting errors
npm run lint
# Auto-fix linting errors
npm run lint:fix
# Format code with Prettier
npm run format
# Check code formatting
npm run format:checkThe project uses ESLint and Prettier for code quality and formatting:
- ESLint: Linting with TypeScript, React, and accessibility rules
- Prettier: Code formatting
- Hooks: React Hooks linting rules
# Check for linting errors
npm run lint
# Auto-fix linting errors
npm run lint:fix
# Format code with Prettier
npm run format
# Check code formatting
npm run format:check- React 18+
- TypeScript 5+
- XState 5+
- Zod 4+ (for validation)
- Node.js 18+
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
MIT
Contributions are welcome! Please feel free to submit a Pull Request.
For issues, questions, or feature requests, please open an issue on the GitHub repository.