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:
react-hook-formfor form handlingzodfor schema validation@tanstack/react-queryfor mutation state management- Shadcn UI for styling
- 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:
currentStepIndex- the index of the current stepdirection- the direction of the transition between steps (for animations)isStepValid- validates the current step using the Zod schemanextStep- moves to the next step if validprevStep- moves to the previous stepgoToStep- jumps to a specific stepisValid- whether the entire form is validerrors- the form errors objectform- the form object fromuseFormmutation- 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.