Components
PreviousNext

Stepper

A stepper component to display step by step content.

Features

  • react-hook-form integration, validation and submit handling
  • Customizable style
  • Navigation controls

Installation

Install the following dependencies:

For typing purposes, make sure that react-hook-form is installed. If you want to use an animated stepper, you need to install motion. Otherwise delete the StepperMotionContent component.

pnpm add react-hook-form motion

Make sure that the following shadcn/ui components are present in your project:

Copy and paste the following code into your project.

components/ui/stepper.tsx
'use client';

import { Button } from '@kit/ui/button';
import { FormField } from '@kit/ui/form';
import { cn } from '@kit/utils';
import {
    StepperActiveStep,
    StepperFormField,
    StepperStep,
    StepperStepLength,
    useStepper,
    useStepperContentBase,
    useStepperForm,
    Stepper as UtilsStepper,
    type StepperProps,
    type StepperStepProps,
} from '@kit/utils/stepper';
import { Slot } from '@radix-ui/react-slot';
import { AnimatePresence, HTMLMotionProps, motion } from 'motion/react';
import React, { useCallback, useEffect } from 'react';

function Stepper(props: Omit<React.ComponentProps<typeof UtilsStepper>, 'FormField'>) {
    return <UtilsStepper {...props} FormField={FormField} />;
}

function StepperContent({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) {
    const { activeStep } = useStepper();
    const { selectedChild } = useStepperContentBase(children);
    const { containerRef } = useStepperAutoFocusNext(activeStep, selectedChild);

    return (
        <div ref={containerRef} {...props}>
            {selectedChild}
        </div>
    );
}

function StepperMotionContent({
    children,
    ...props
}: { children: React.ReactNode } & HTMLMotionProps<'div'>) {
    const { activeStep } = useStepper();
    const { selectedChild } = useStepperContentBase(children);
    const { containerRef } = useStepperAutoFocusNext(activeStep, selectedChild);

    return (
        <AnimatePresence mode="wait">
            <motion.div
                key={activeStep}
                initial={{ x: 40, opacity: 0 }}
                animate={{ x: 0, opacity: 1 }}
                exit={{ x: -40, opacity: 0 }}
                transition={{ duration: 0.2 }}
                {...props}
                ref={containerRef}
            >
                {selectedChild}
            </motion.div>
        </AnimatePresence>
    );
}

function useStepperAutoFocusNext(activeStep: number, dependency?: unknown) {
    const containerRef = React.useRef<HTMLDivElement | null>(null);
    const prevStepRef = React.useRef<number>(activeStep);

    useEffect(() => {
        const previous = prevStepRef.current;
        if (activeStep > previous) {
            const id = setTimeout(() => {
                const root = containerRef.current;
                if (!root) return;
                const el = root.querySelector<HTMLElement>(
                    'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
                );
                el?.focus?.();
            }, 0);
            return () => clearTimeout(id);
        }
        return;
    }, [activeStep, containerRef, dependency]);

    useEffect(() => {
        prevStepRef.current = activeStep;
    }, [activeStep]);

    return { containerRef };
}

export interface StepperPreviousProps {
    asChild?: boolean;
    onClick?: (event: React.MouseEvent<HTMLButtonElement>, step?: number) => void;
}

const StepperPrevious = React.forwardRef<
    HTMLButtonElement,
    StepperPreviousProps &
        Omit<React.ComponentPropsWithoutRef<typeof Button>, 'aria-label' | 'onClick' | 'asChild'>
>(({ className, children, variant, disabled, onClick, ...props }, ref) => {
    const { activeStep, movePrevious, isFirstStep } = useStepper();

    const handlePrevClick = useCallback(
        async (e: React.MouseEvent<HTMLButtonElement>) => {
            e.preventDefault();
            e.stopPropagation();

            if (isFirstStep()) return;
            onClick?.(e, activeStep);
            movePrevious();
        },
        [activeStep, movePrevious, onClick]
    );

    return (
        <Button
            ref={ref}
            aria-label="Previous"
            onClick={handlePrevClick}
            disabled={typeof disabled === 'boolean' ? disabled : isFirstStep()}
            className={cn(className)}
            variant={variant ?? 'outline'}
            {...props}
        >
            {children || '← Previous'}
        </Button>
    );
});
StepperPrevious.displayName = 'StepperPrevious';

export interface StepperNextProps {
    asChild?: boolean;
    /**
     * The children of the next button when the last step is reached.
     */
    lastChildren?: React.ReactNode;
    /**
     * Replace the all component for the last step.
     */
    replaceForLast?: React.ReactNode;
    onClick?: (event?: React.MouseEvent<HTMLButtonElement>, step?: number) => void;
    onLastClick?: () => void;
    canGoNext?: (step: number) => Promise<boolean>;
}

const StepperNext = React.forwardRef<
    HTMLButtonElement,
    StepperNextProps &
        Omit<React.ComponentPropsWithoutRef<typeof Button>, 'aria-label' | 'onClick' | 'asChild'>
>(
    (
        {
            className,
            children,
            variant,
            disabled,
            lastChildren,
            replaceForLast,
            onClick,
            canGoNext,
            onLastClick,
            ...props
        },
        ref
    ) => {
        const { activeStep, moveNext, isLastStep } = useStepper();
        const { isDirtyGateOpen, validateStep, blockedSteps, reactForm, onSubmit } = useStepperForm();

        const isLast = isLastStep();

        const handleNextClick = useCallback(
            async (e: React.MouseEvent<HTMLButtonElement>) => {
                const isLast = isLastStep();
                if (!isLast || !reactForm || onSubmit) {
                    e.preventDefault();
                    e.stopPropagation();
                }

                if (canGoNext && !(await canGoNext(activeStep))) {
                    e.preventDefault();
                    e.stopPropagation();
                    return;
                }

                const valid = await validateStep();
                if (!valid) {
                    e.preventDefault();
                    e.stopPropagation();
                    return;
                }

                onClick?.(e, activeStep);

                if (isLast) {
                    onLastClick?.();
                    onSubmit?.();
                    return;
                }

                moveNext();
            },
            [
                activeStep,
                moveNext,
                onClick,
                reactForm,
                isLastStep,
                validateStep,
                canGoNext,
                onLastClick,
                isLast,
                onSubmit,
            ]
        );

        if (isLast) {
            if (replaceForLast) return replaceForLast;
            if (reactForm) {
                return (
                    <Button
                        ref={ref}
                        aria-label="Submit"
                        role="button"
                        type={onSubmit ? 'button' : 'submit'}
                        onClick={handleNextClick}
                        className={cn(className)}
                        variant={variant ?? 'default'}
                        disabled={
                            typeof disabled === 'boolean'
                                ? disabled
                                : reactForm.formState.isSubmitSuccessful ||
                                  blockedSteps[activeStep] ||
                                  !isDirtyGateOpen
                        }
                        {...props}
                    >
                        {lastChildren ??
                            (reactForm.formState.isSubmitting ? (
                                <>Submitting...</>
                            ) : reactForm.formState.isSubmitSuccessful ? (
                                'Registration Complete!'
                            ) : (
                                'Complete Registration'
                            ))}
                    </Button>
                );
            }
        }

        return (
            <Button
                ref={ref}
                aria-label={isLast ? 'Done' : 'Next'}
                onClick={handleNextClick}
                className={cn(
                    isLast && !onClick && !reactForm ? 'pointer-events-none opacity-0' : '',
                    className
                )}
                variant={variant ?? 'default'}
                disabled={
                    typeof disabled === 'boolean' ? disabled : blockedSteps[activeStep] || !isDirtyGateOpen
                }
                {...props}
            >
                {isLast ? lastChildren || 'Done' : children || 'Next →'}
            </Button>
        );
    }
);
StepperNext.displayName = 'StepperNext';

export interface StepperTriggerProps {
    asChild?: boolean;
    step: number;
}

function StepperTrigger({
    asChild,
    className,
    step,
    children,
}: StepperTriggerProps & React.ComponentPropsWithoutRef<'div'>) {
    const Comp = asChild ? Slot : 'div';
    const { onStepChange, activeStep, disableForwardNav } = useStepper();

    const handleClick = useCallback(() => {
        if (disableForwardNav && step > activeStep) return;
        onStepChange(step);
    }, [step, onStepChange, disableForwardNav, activeStep]);

    return (
        <Comp
            className={cn(
                'group/stepper-trigger cursor-pointer',
                disableForwardNav && activeStep < step && 'pointer-events-none opacity-50',
                className
            )}
            data-slot="stepper-trigger"
            data-state={activeStep === step ? 'active' : activeStep > step ? 'complete' : 'upcoming'}
            onClick={handleClick}
        >
            {children}
        </Comp>
    );
}

export { Stepper, StepperContent, StepperMotionContent, StepperNext, StepperPrevious, StepperTrigger };

/* from utils */
export {
    StepperActiveStep,
    StepperFormField,
    StepperStep,
    StepperStepLength,
    type StepperProps,
    type StepperStepProps,
};

Update the import paths to match your project setup.

Usage

import { Stepper, StepperContent, StepperNext, StepperPrevious, StepperStep, StepperTrigger } from '@kit/ui/stepper';
<Stepper> <nav className="flex gap-2"> <StepperTrigger step={1}>Step 1</StepperTrigger> <StepperTrigger step={2}>Step 2</StepperTrigger> <StepperTrigger step={3}>Step 3</StepperTrigger> </nav> <StepperContent> <StepperStep step={1}> <>{/* Step content 1 */}</> </StepperStep> <StepperStep step={2}> <>{/* Step content 2 */}</> </StepperStep> <StepperStep step={3}> <>{/* Step content 3 */}</> </StepperStep> </StepperContent> <div className="mt-12 flex flex-row-reverse"> <StepperNext /> <StepperPrevious /> </div> </Stepper>

Anatomy

Stepper anatomy diagram

Stepper anatomy diagram

Examples

Motion

You can easily animate step transitions using motion and its AnimatePresence component.

To do that you need to use the StepperMotionContent component instead of the StepperContent component.

<Stepper>
    <StepperMotionContent
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
    >
        {/* Your steps come here ... */}
    </StepperMotionContent>
 
    <div className="mt-12 flex flex-row-reverse">
        <StepperNext />
        <StepperPrevious />
    </div>
</Stepper>

Form implementation

The example at the top shows a simple stepper component with form validation and submit handling.

Follow these instructions to properly implement the stepper component with form validation and submit handling.

Declare the form methods and schema

Optional: set disableForwardNav to true to disable the forward navigation, prevent your users to skip a step without validating the previous steps.

We are using the name props of the <FormField /> children to detect which fields are present in the current step.

const methods = useForm<FormData>({ mode: 'onChange', resolver: zodResolver(yourSchema), defaultValues: {/* ... */} }); return ( <Stepper reactForm={methods} disableForwardNav> <Form {...methods}> <form onSubmit={methods.handleSubmit(console.log)} className="w-full space-y-6"> <StepperContent> <StepperStep> <FormField control={methods.control} name="firstName" render={({ field }) => ( <FormItem className="flex w-full flex-col"> <FormLabel>First Name</FormLabel> <FormControl> <Input type="text" maxLength={512} disabled={methods.formState.isSubmitting} placeholder="John" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={methods.control} name="lastName" render={({ field }) => ( <FormItem className="flex w-full flex-col"> <FormLabel>Last Name</FormLabel> <FormControl> <Input type="text" maxLength={512} disabled={methods.formState.isSubmitting} placeholder="Doe" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </StepperStep> {/* other steps come here ... */} </StepperContent> {/* flex row reverse for focus control */} <div className="mt-12 flex flex-row-reverse justify-between"> <StepperNext /> <StepperPrevious /> </div> </form> </Form> </Stepper> );

Progress Bar variant

Here are some premade progress bar that you can use or adapt to your needs.

Simple

Bullets

Panels Borders

Circles

Connecting Lines

Dots

Panels Progress

API Reference

Root

PropTypeDefault
step
number
onStepChange
function
children*
React.ReactNode
reactForm
UseFormReturn<any>
requireDirtyOnStep
boolean
false
numberOfSteps
number
disableForwardNav
boolean
false
onSubmit
function

Content

Props: React.ComponentPropsWithoutRef<'div'>

MotionContent

Motion version of the StepperContent component.

Props: { children: React.ReactNode } & HTMLMotionProps<'div'>

Step

Allow to define the content of a single step.

StepperStepProps

PropTypeDefault
step
number
children*
React.ReactNode

Previous

Previous step button.

StepperPreviousProps

PropTypeDefault
asChild
boolean
onClick
function

Next

Next step button.

StepperNextProps

PropTypeDefault
asChild
boolean
lastChildren
React.ReactNode
replaceForLast
React.ReactNode
onClick
function
onLastClick
function
canGoNext
function

Trigger

StepperTriggerProps

PropTypeDefault
asChild
boolean
step*
number

ActiveStep

A React Fragment that contains the active step number.

StepLength

A React Fragment that contains the number of steps.

How is this guide?

Last updated on 1/8/2026