# 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.

*Published: 2024-07-09*
*Canonical: https://makerkit.dev/blog/changelog/multi-step-forms-reactjs*

---

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.

{% Video src="/assets/videos/multi-step-demo.mp4" /%}

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

```tsx
'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](/docs/next-supabase-turbo/components/multi-step-forms).

#### 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:

```tsx
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:

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

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

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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](/docs/next-supabase-turbo/components/stepper) by injecting context via `MultiStepFormContextProvider`:

```tsx
<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:

```tsx
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`:

```tsx
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:

```tsx
'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](/blog/changelog/delightful-onboarding-multi-step-forms).
