ยทUpdated

Building Multi-Step Forms with React

Learn how to build multi-step forms in React with per-step validation using React Hook Form, Zod, and smooth animated transitions.

In this post, I'll show you how Makerkit implements multi-step forms using React Hook Form, Zod, and Shadcn UI. The pattern handles per-step validation, animated transitions, and a clean API that's easy to extend.

Updated February 2026: Synced with the latest Makerkit implementation, including TanStack Query mutation support and the new Radix UI import patterns.

What you'll learn: Build a multi-step form component using React Hook Form for state management, Zod schemas for per-step validation, and React context to share form state across step components. The hook also includes TanStack Query mutation support for async submissions.

The component lets users move between steps, validates each step before proceeding, and submits the complete form. Here's how you use it:

'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
MultiStepForm,
MultiStepFormContextProvider,
MultiStepFormHeader,
MultiStepFormStep,
createStepSchema,
useMultiStepFormContext,
} from '@kit/ui/multi-step-form';
import { Stepper } from '@kit/ui/stepper';
const FormSchema = createStepSchema({
account: z.object({
username: z.string().min(3),
email: z.string().email(),
}),
profile: z.object({
password: z.string().min(8),
age: z.coerce.number().min(18),
}),
});
type FormValues = z.infer<typeof FormSchema>;
export function MultiStepFormDemo() {
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
account: {
username: '',
email: '',
},
profile: {
password: '',
},
},
reValidateMode: 'onBlur',
mode: 'onBlur',
});
const onSubmit = (data: FormValues) => {
console.log('Form submitted:', data);
};
return (
<MultiStepForm
className={'space-y-10 p-8 rounded-xl border'}
schema={FormSchema}
form={form}
onSubmit={onSubmit}
>
<MultiStepFormHeader
className={'flex w-full flex-col justify-center space-y-6'}
>
<h2 className={'text-xl font-bold'}>Create your account</h2>
<MultiStepFormContextProvider>
{({ currentStepIndex }) => (
<Stepper
variant={'numbers'}
steps={['Account', 'Profile', 'Review']}
currentStep={currentStepIndex}
/>
)}
</MultiStepFormContextProvider>
</MultiStepFormHeader>
<MultiStepFormStep name="account">
<AccountStep />
</MultiStepFormStep>
<MultiStepFormStep name="profile">
<ProfileStep />
</MultiStepFormStep>
<MultiStepFormStep name="review">
<ReviewStep />
</MultiStepFormStep>
</MultiStepForm>
);
}
function AccountStep() {
const { form, nextStep, isStepValid } = useMultiStepFormContext();
return (
<Form {...form}>
<div className={'flex flex-col gap-4'}>
<FormField
name="account.username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="account.email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button onClick={nextStep} disabled={!isStepValid()}>
Next
</Button>
</div>
</div>
</Form>
);
}
function ProfileStep() {
const { form, nextStep, prevStep } = useMultiStepFormContext();
return (
<Form {...form}>
<div className={'flex flex-col gap-4'}>
<FormField
name="profile.password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="profile.age"
render={({ field }) => (
<FormItem>
<FormLabel>Age</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button type={'button'} variant={'outline'} onClick={prevStep}>
Previous
</Button>
<Button onClick={nextStep}>Next</Button>
</div>
</div>
</Form>
);
}
function ReviewStep() {
const { prevStep, form } = useMultiStepFormContext<typeof FormSchema>();
const values = form.getValues();
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-4'}>
<div>Great! Please review the values.</div>
<div className={'flex flex-col space-y-2 text-sm'}>
<div>
<span>Username</span>: <span>{values.account.username}</span>
</div>
<div>
<span>Email</span>: <span>{values.account.email}</span>
</div>
<div>
<span>Age</span>: <span>{values.profile.age}</span>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type={'button'} variant={'outline'} onClick={prevStep}>
Back
</Button>
<Button type={'submit'}>Create Account</Button>
</div>
</div>
);
}

Dependencies

Before starting, this tutorial uses:

  1. react-hook-form for form handling
  2. zod for schema validation
  3. @tanstack/react-query for mutation state management
  4. Shadcn UI for styling
  5. Makerkit's imports (replaceable with your own Shadcn UI setup)

Tested with: React 19, react-hook-form 7.54, zod 3.24, @tanstack/react-query 5.64

The code is generic enough to apply to other UI libraries. For the full API reference, check out the Multi-Step Form documentation.

Multi-Step Form Component

The MultiStepForm component handles the multi-step form. It uses the useMultiStepForm hook to manage form state and step transitions.

Here are the interfaces and types:

interface MultiStepFormProps<T extends z.ZodType> {
schema: T;
form: UseFormReturn<z.infer<T>>;
onSubmit: (data: z.infer<T>) => void;
useStepTransition?: boolean;
className?: string;
}
type StepProps = React.PropsWithChildren<
{
name: string;
asChild?: boolean;
} & React.HTMLProps<HTMLDivElement>
>;

Multi-Step Form Context

The MultiStepFormContext passes form state and methods to child components. We reuse this context across steps:

const MultiStepFormContext = createContext<ReturnType<
typeof useMultiStepForm
> | null>(null);

The useMultiStepFormContext hook returns the context value and throws if used outside a MultiStepForm:

export function useMultiStepFormContext<Schema extends z.ZodType>() {
const context = useContext(MultiStepFormContext) as ReturnType<
typeof useMultiStepForm<Schema>
>;
if (!context) {
throw new Error(
'useMultiStepFormContext must be used within a MultiStepForm',
);
}
return context;
}

Multi-Step Form Hook

The useMultiStepForm hook is the core of the component. It manages form state, step transitions, per-step validation, and exposes a mutation object for handling async submissions.

The hook validates each step using the Zod schema before allowing navigation to the next step:

export function useMultiStepForm<Schema extends z.ZodType>(
schema: Schema,
form: UseFormReturn<z.infer<Schema>>,
stepNames: string[],
onSubmit: (data: z.infer<Schema>) => void,
) {
const [state, setState] = useState({
currentStepIndex: 0,
direction: undefined as 'forward' | 'backward' | undefined,
});
const isStepValid = useCallback(() => {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
// the user may not want to validate the current step
// or the step doesn't contain any form field
if (!currentStepSchema) {
return true;
}
const currentStepData = form.getValues(currentStepName) ?? {};
const result = currentStepSchema.safeParse(currentStepData);
return result.success;
}
throw new Error(`Unsupported schema type: ${schema.constructor.name}`);
}, [schema, form, stepNames, state.currentStepIndex]);
const nextStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
// prevent form submission when the user presses Enter
// or if the user forgets [type="button"] on the button
e.preventDefault();
const isValid = isStepValid();
if (!isValid) {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
if (currentStepSchema) {
const fields = Object.keys(
(currentStepSchema as z.ZodObject<never>).shape,
);
const keys = fields.map((field) => `${currentStepName}.${field}`);
// trigger validation for all fields in the current step
for (const key of keys) {
void form.trigger(key as Path<z.TypeOf<Schema>>);
}
return;
}
}
}
if (isValid && state.currentStepIndex < stepNames.length - 1) {
setState((prevState) => ({
...prevState,
direction: 'forward',
currentStepIndex: prevState.currentStepIndex + 1,
}));
}
},
[isStepValid, state.currentStepIndex, stepNames, schema, form],
);
const prevStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
// prevent form submission when the user presses Enter
// or if the user forgets [type="button"] on the button
e.preventDefault();
if (state.currentStepIndex > 0) {
setState((prevState) => ({
...prevState,
direction: 'backward',
currentStepIndex: prevState.currentStepIndex - 1,
}));
}
},
[state.currentStepIndex],
);
const goToStep = useCallback(
(index: number) => {
if (index >= 0 && index < stepNames.length && isStepValid()) {
setState((prevState) => ({
...prevState,
direction:
index > prevState.currentStepIndex ? 'forward' : 'backward',
currentStepIndex: index,
}));
}
},
[isStepValid, stepNames.length],
);
const isValid = form.formState.isValid;
const errors = form.formState.errors;
const mutation = useMutation({
mutationFn: () => {
return form.handleSubmit(onSubmit)();
},
});
return useMemo(
() => ({
form,
currentStep: stepNames[state.currentStepIndex] as string,
currentStepIndex: state.currentStepIndex,
totalSteps: stepNames.length,
isFirstStep: state.currentStepIndex === 0,
isLastStep: state.currentStepIndex === stepNames.length - 1,
nextStep,
prevStep,
goToStep,
direction: state.direction,
isStepValid,
isValid,
errors,
mutation,
}),
[
form,
mutation,
stepNames,
state.currentStepIndex,
state.direction,
nextStep,
prevStep,
goToStep,
isStepValid,
isValid,
errors,
],
);
}

The hook returns:

  1. currentStepIndex - the index of the current step
  2. direction - the direction of the transition between steps (for animations)
  3. isStepValid - validates the current step using the Zod schema
  4. nextStep - moves to the next step if valid
  5. prevStep - moves to the previous step
  6. goToStep - jumps to a specific step
  7. isValid - whether the entire form is valid
  8. errors - the form errors object
  9. form - the form object from useForm
  10. mutation - TanStack Query mutation for async submission handling

Step Transitions

The AnimatedStep component handles smooth transitions between steps using CSS:

interface AnimatedStepProps {
direction: 'forward' | 'backward' | undefined;
isActive: boolean;
index: number;
currentIndex: number;
}
function AnimatedStep({
isActive,
direction,
children,
index,
currentIndex,
}: React.PropsWithChildren<AnimatedStepProps>) {
const [shouldRender, setShouldRender] = useState(isActive);
const stepRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive) {
setShouldRender(true);
} else {
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [isActive]);
useEffect(() => {
if (isActive && stepRef.current) {
const focusableElement = stepRef.current.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElement) {
(focusableElement as HTMLElement).focus();
}
}
}, [isActive]);
if (!shouldRender) {
return null;
}
const baseClasses =
'top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95';
const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute';
const transformClasses = cn(
'translate-x-0',
isActive
? {}
: {
'-translate-x-full': direction === 'forward' || index < currentIndex,
'translate-x-full': direction === 'backward' || index > currentIndex,
},
);
const className = cn(baseClasses, visibilityClasses, transformClasses);
return (
<div ref={stepRef} className={className} aria-hidden={!isActive}>
{children}
</div>
);
}

The component also auto-focuses the first focusable element when a step becomes active, which improves keyboard accessibility.

Slot Components

These components act as slots for the header, footer, and individual steps. They use the Radix UI Slot primitive to allow flexible composition:

export const MultiStepFormStep: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
ref?: React.Ref<HTMLDivElement>;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormStep({ children, asChild, ...props }) {
const Cmp = asChild ? Slot.Root : 'div';
return (
<Cmp {...props}>
<Slot.Slottable>{children}</Slot.Slottable>
</Cmp>
);
};
export const MultiStepFormHeader: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormHeader({ children, asChild, ...props }) {
const Cmp = asChild ? Slot.Root : 'div';
return (
<Cmp {...props}>
<Slot.Slottable>{children}</Slot.Slottable>
</Cmp>
);
};
export const MultiStepFormFooter: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormFooter({ children, asChild, ...props }) {
const Cmp = asChild ? Slot.Root : 'div';
return (
<Cmp {...props}>
<Slot.Slottable>{children}</Slot.Slottable>
</Cmp>
);
};

Add a header with a Stepper component by injecting context via MultiStepFormContextProvider:

<MultiStepFormHeader>
<MultiStepFormContextProvider>
{(ctx) => (
<Stepper
currentStep={ctx.currentStepIndex}
totalSteps={ctx.totalSteps}
/>
)}
</MultiStepFormContextProvider>
</MultiStepFormHeader>

Putting It All Together

The MultiStepForm component ties everything together. It extracts steps, header, and footer from children and manages the form context:

export function MultiStepForm<T extends z.ZodType>({
schema,
form,
onSubmit,
children,
className,
}: React.PropsWithChildren<MultiStepFormProps<T>>) {
const steps = useMemo(
() =>
React.Children.toArray(children).filter(
(child): child is React.ReactElement<StepProps> =>
React.isValidElement(child) && child.type === MultiStepFormStep,
),
[children],
);
const header = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormHeader,
);
}, [children]);
const footer = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormFooter,
);
}, [children]);
const stepNames = steps.map((step) => step.props.name);
const multiStepForm = useMultiStepForm(schema, form, stepNames, onSubmit);
return (
<MultiStepFormContext.Provider value={multiStepForm}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn(className, 'flex size-full flex-col overflow-hidden')}
>
{header}
<div className="relative transition-transform duration-500">
{steps.map((step, index) => {
const isActive = index === multiStepForm.currentStepIndex;
return (
<AnimatedStep
key={step.props.name}
direction={multiStepForm.direction}
isActive={isActive}
index={index}
currentIndex={multiStepForm.currentStepIndex}
>
{step}
</AnimatedStep>
);
})}
</div>
{footer}
</form>
</MultiStepFormContext.Provider>
);
}

The createStepSchema Helper

This utility creates a schema for the multi-step form. It's a simple wrapper around z.object:

export function createStepSchema<T extends Record<string, z.ZodType>>(
steps: T,
) {
return z.object(steps);
}

Full Source Code

Here's the complete implementation from Makerkit's source code:

'use client';
import React, {
HTMLProps,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useMutation } from '@tanstack/react-query';
import { Slot } from 'radix-ui';
import { Path, UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '../lib/utils';
interface MultiStepFormProps<T extends z.ZodType> {
schema: T;
form: UseFormReturn<z.infer<T>>;
onSubmit: (data: z.infer<T>) => void;
useStepTransition?: boolean;
className?: string;
}
type StepProps = React.PropsWithChildren<
{
name: string;
asChild?: boolean;
} & React.HTMLProps<HTMLDivElement>
>;
const MultiStepFormContext = createContext<ReturnType<
typeof useMultiStepForm
> | null>(null);
/**
* @name MultiStepForm
* @description Multi-step form component for React
*/
export function MultiStepForm<T extends z.ZodType>({
schema,
form,
onSubmit,
children,
className,
}: React.PropsWithChildren<MultiStepFormProps<T>>) {
const steps = useMemo(
() =>
React.Children.toArray(children).filter(
(child): child is React.ReactElement<StepProps> =>
React.isValidElement(child) && child.type === MultiStepFormStep,
),
[children],
);
const header = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormHeader,
);
}, [children]);
const footer = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormFooter,
);
}, [children]);
const stepNames = steps.map((step) => step.props.name);
const multiStepForm = useMultiStepForm(schema, form, stepNames, onSubmit);
return (
<MultiStepFormContext.Provider value={multiStepForm}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn(className, 'flex size-full flex-col overflow-hidden')}
>
{header}
<div className="relative transition-transform duration-500">
{steps.map((step, index) => {
const isActive = index === multiStepForm.currentStepIndex;
return (
<AnimatedStep
key={step.props.name}
direction={multiStepForm.direction}
isActive={isActive}
index={index}
currentIndex={multiStepForm.currentStepIndex}
>
{step}
</AnimatedStep>
);
})}
</div>
{footer}
</form>
</MultiStepFormContext.Provider>
);
}
export function MultiStepFormContextProvider(props: {
children: (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode;
}) {
const ctx = useMultiStepFormContext();
if (Array.isArray(props.children)) {
const [child] = props.children;
return (
child as (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode
)(ctx);
}
return props.children(ctx);
}
export const MultiStepFormStep: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
ref?: React.Ref<HTMLDivElement>;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormStep({ children, asChild, ...props }) {
const Cmp = asChild ? Slot.Root : 'div';
return (
<Cmp {...props}>
<Slot.Slottable>{children}</Slot.Slottable>
</Cmp>
);
};
export function useMultiStepFormContext<Schema extends z.ZodType>() {
const context = useContext(MultiStepFormContext) as ReturnType<
typeof useMultiStepForm<Schema>
>;
if (!context) {
throw new Error(
'useMultiStepFormContext must be used within a MultiStepForm',
);
}
return context;
}
/**
* @name useMultiStepForm
* @description Hook for multi-step forms
*/
export function useMultiStepForm<Schema extends z.ZodType>(
schema: Schema,
form: UseFormReturn<z.infer<Schema>>,
stepNames: string[],
onSubmit: (data: z.infer<Schema>) => void,
) {
const [state, setState] = useState({
currentStepIndex: 0,
direction: undefined as 'forward' | 'backward' | undefined,
});
const isStepValid = useCallback(() => {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
// the user may not want to validate the current step
// or the step doesn't contain any form field
if (!currentStepSchema) {
return true;
}
const currentStepData = form.getValues(currentStepName) ?? {};
const result = currentStepSchema.safeParse(currentStepData);
return result.success;
}
throw new Error(`Unsupported schema type: ${schema.constructor.name}`);
}, [schema, form, stepNames, state.currentStepIndex]);
const nextStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
e.preventDefault();
const isValid = isStepValid();
if (!isValid) {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
if (currentStepSchema) {
const fields = Object.keys(
(currentStepSchema as z.ZodObject<never>).shape,
);
const keys = fields.map((field) => `${currentStepName}.${field}`);
for (const key of keys) {
void form.trigger(key as Path<z.TypeOf<Schema>>);
}
return;
}
}
}
if (isValid && state.currentStepIndex < stepNames.length - 1) {
setState((prevState) => ({
...prevState,
direction: 'forward',
currentStepIndex: prevState.currentStepIndex + 1,
}));
}
},
[isStepValid, state.currentStepIndex, stepNames, schema, form],
);
const prevStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
e.preventDefault();
if (state.currentStepIndex > 0) {
setState((prevState) => ({
...prevState,
direction: 'backward',
currentStepIndex: prevState.currentStepIndex - 1,
}));
}
},
[state.currentStepIndex],
);
const goToStep = useCallback(
(index: number) => {
if (index >= 0 && index < stepNames.length && isStepValid()) {
setState((prevState) => ({
...prevState,
direction:
index > prevState.currentStepIndex ? 'forward' : 'backward',
currentStepIndex: index,
}));
}
},
[isStepValid, stepNames.length],
);
const isValid = form.formState.isValid;
const errors = form.formState.errors;
const mutation = useMutation({
mutationFn: () => {
return form.handleSubmit(onSubmit)();
},
});
return useMemo(
() => ({
form,
currentStep: stepNames[state.currentStepIndex] as string,
currentStepIndex: state.currentStepIndex,
totalSteps: stepNames.length,
isFirstStep: state.currentStepIndex === 0,
isLastStep: state.currentStepIndex === stepNames.length - 1,
nextStep,
prevStep,
goToStep,
direction: state.direction,
isStepValid,
isValid,
errors,
mutation,
}),
[
form,
mutation,
stepNames,
state.currentStepIndex,
state.direction,
nextStep,
prevStep,
goToStep,
isStepValid,
isValid,
errors,
],
);
}
export const MultiStepFormHeader: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormHeader({ children, asChild, ...props }) {
const Cmp = asChild ? Slot.Root : 'div';
return (
<Cmp {...props}>
<Slot.Slottable>{children}</Slot.Slottable>
</Cmp>
);
};
export const MultiStepFormFooter: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormFooter({ children, asChild, ...props }) {
const Cmp = asChild ? Slot.Root : 'div';
return (
<Cmp {...props}>
<Slot.Slottable>{children}</Slot.Slottable>
</Cmp>
);
};
/**
* @name createStepSchema
* @description Create a schema for a multi-step form
*/
export function createStepSchema<T extends Record<string, z.ZodType>>(
steps: T,
) {
return z.object(steps);
}
interface AnimatedStepProps {
direction: 'forward' | 'backward' | undefined;
isActive: boolean;
index: number;
currentIndex: number;
}
function AnimatedStep({
isActive,
direction,
children,
index,
currentIndex,
}: React.PropsWithChildren<AnimatedStepProps>) {
const [shouldRender, setShouldRender] = useState(isActive);
const stepRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive) {
setShouldRender(true);
} else {
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [isActive]);
useEffect(() => {
if (isActive && stepRef.current) {
const focusableElement = stepRef.current.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElement) {
(focusableElement as HTMLElement).focus();
}
}
}, [isActive]);
if (!shouldRender) {
return null;
}
const baseClasses =
'top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95';
const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute';
const transformClasses = cn(
'translate-x-0',
isActive
? {}
: {
'-translate-x-full': direction === 'forward' || index < currentIndex,
'translate-x-full': direction === 'backward' || index > currentIndex,
},
);
const className = cn(baseClasses, visibilityClasses, transformClasses);
return (
<div ref={stepRef} className={className} aria-hidden={!isActive}>
{children}
</div>
);
}

Conclusion

This article covered building multi-step forms with React Hook Form and Zod validation. The component handles per-step validation, animated transitions, and integrates with TanStack Query for mutation state management.

You can use this component in your apps to create multi-step forms that validate each step before proceeding and provide a smooth user experience. For a practical example, see our tutorial on building delightful onboarding flows with multi-step forms.

Some other posts you might like...