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 { cn } from '@kit/shared';
import { Button } from '@kit/ui/button';
import { FormField } from '@kit/ui/form';
import { Slot } from '@radix-ui/react-slot';
import { AnimatePresence, HTMLMotionProps, motion } from 'motion/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { UseFormReturn } from 'react-hook-form';

// Utility: collect field names recursively from a React node tree
const collectFieldNames = (node: React.ReactNode, acc: Set<string>) => {
    if (node == null || typeof node === 'boolean') return;
    if (Array.isArray(node)) {
        for (const child of node) collectFieldNames(child, acc);
        return;
    }
    if (typeof node === 'string' || typeof node === 'number') return;
    if (React.isValidElement(node)) {
        // Specifically detect our FormField component and extract its 'name' prop
        if (node.type === FormField) {
            const maybeName = (node.props as any)?.name;
            if (typeof maybeName === 'string') {
                acc.add(maybeName);
            }
        }
        const childrenProp = (node.props as any)?.children;
        if (childrenProp) collectFieldNames(childrenProp, acc);
    }
};

const areArraysEqual = (a: string[] | undefined, b: string[]) => {
    if (!a) return false;
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
        if (a[i] !== b[i]) return false;
    }
    return true;
};

interface StepperContextType {
    activeStep: number;
    /**
     * Number of direct StepperStep children inside StepperContent
     */
    stepLength: number | null;
    /**
     * Update the number of steps (called by StepperContent)
     * @param length
     * @returns
     */
    setStepLength: (length: number) => void;
    /**
     * Allow consumers to programmatically change step
     *
     * @param step
     * @returns
     */
    onStepChange: (step: number) => void;
    moveNext: () => void;
    movePrevious: () => void;
    isFirstStep: () => boolean;
    isLastStep: () => boolean;
    /**
     * If true, prevent StepperTrigger from navigating forward (only backward allowed)
     */
    disableForwardNav: boolean;
}

const StepperContext = React.createContext<StepperContextType | undefined>(undefined);

function useStepper(): StepperContextType {
    const context = React.useContext(StepperContext);
    if (!context) {
        throw new Error('Stepper components must be used within a Stepper');
    }
    return context;
}

interface StepperFormContextType {
    /**
     * The reactForm of the form
     */
    reactForm: UseFormReturn<any> | undefined;
    /**
     * Detect the field names contained in a specific step react node tree
     *
     * @param stepChildren - The children of the step
     * @returns - The field names contained in the step
     */
    detectStepFieldNames: (
        stepChildren: React.ReactElement<
            StepperStepProps & {
                children: React.ReactNode;
            },
            string | React.JSXElementConstructor<any>
        >[]
    ) => void;
    /**
     * Whether the Next button is blocked for a given step due to failed validation
     */
    blockedSteps: Record<number, boolean>;
    /**
     * Whether the Next button is open for a given step due to requireDirtyOnStep
     */
    isDirtyGateOpen: boolean;
    /**
     * Validate the current step
     */
    validateStep: () => Promise<boolean>;
}

const StepperFormContext = React.createContext<StepperFormContextType | undefined>(undefined);

function useStepperForm(): StepperFormContextType {
    const context = React.useContext(StepperFormContext);
    if (!context) {
        throw new Error('Stepper form features must be used within a Stepper');
    }
    return context;
}

function useStepperFormContext({
    reactForm,
    activeStep,
    requireDirtyOnStep,
}: Pick<StepperProps, 'reactForm' | 'requireDirtyOnStep'> & { activeStep: number }): StepperFormContextType {
    const [stepToFieldNames, setStepToFieldNames] = useState<Record<number, string[]>>({});
    const [blockedSteps, setBlockedSteps] = useState<Record<number, boolean>>({});

    // When the current step is blocked due to failed validation, unblock it
    // as soon as any field in that step changes
    useEffect(() => {
        if (!reactForm) return;
        if (!blockedSteps[activeStep]) return;
        const fields = stepToFieldNames[activeStep] ?? [];
        if (fields.length === 0) return;

        const subscription = reactForm.watch((_value, { name }) => {
            if (!name) return;
            if (fields.includes(name)) {
                setBlockedSteps((prev) => (prev[activeStep] ? { ...prev, [activeStep]: false } : prev));
            }
        });

        return () => {
            try {
                subscription?.unsubscribe?.();
            } catch (_e) {
                // noop
            }
        };
    }, [reactForm, activeStep, blockedSteps, stepToFieldNames]);

    const registerStepFieldNames = useCallback(
        (
            stepChildren: React.ReactElement<
                StepperStepProps & {
                    children: React.ReactNode;
                },
                string | React.JSXElementConstructor<any>
            >[]
        ) => {
            // For each step, compute and register its field names only if changed
            for (let index = 0; index < stepChildren.length; index++) {
                const child = stepChildren[index];
                if (!child || !React.isValidElement(child)) continue;
                const stepNumber =
                    typeof (child.props as any)?.step === 'number' ? (child.props as any).step : index + 1;
                const namesSet = new Set<string>();
                collectFieldNames(child.props.children, namesSet);
                const names = Array.from(namesSet).sort();
                const prev = stepToFieldNames[stepNumber]?.slice().sort();
                if (!areArraysEqual(prev, names)) {
                    setStepToFieldNames((prev) => ({ ...prev, [stepNumber]: names }));
                }
            }
        },
        []
    );

    const isDirtyGateOpen = useMemo(() => {
        if (!requireDirtyOnStep || !reactForm) return true;
        const fields = stepToFieldNames[activeStep] ?? [];
        if (fields.length === 0) return true;
        const dirtyMap = (reactForm.formState.dirtyFields ?? {}) as Record<string, unknown>;
        return fields.some((name) => {
            const flag = dirtyMap[name];
            return typeof flag === 'boolean' ? flag : !!flag;
        });
    }, [requireDirtyOnStep, reactForm, activeStep, stepToFieldNames]);

    const validateStep = useCallback(async () => {
        if (!reactForm) return true;
        const fields = stepToFieldNames[activeStep] ?? [];
        if (fields.length === 0) return true;
        const valid = await reactForm.trigger(fields as any, { shouldFocus: true });
        if (!valid) {
            setBlockedSteps((prev) => (prev[activeStep] ? prev : { ...prev, [activeStep]: true }));
        }
        return valid;
    }, [reactForm, activeStep, stepToFieldNames]);

    return useMemo(
        () => ({
            reactForm,
            detectStepFieldNames: registerStepFieldNames,
            blockedSteps,
            isDirtyGateOpen,
            validateStep,
        }),
        [reactForm, requireDirtyOnStep, stepToFieldNames, blockedSteps]
    );
}

export interface StepperProps {
    step?: number;
    onStepChange?: (step: number) => void;
    children: React.ReactNode;
    /**
     * Pass your `react-hook-form` instance to enable per-step validation and submit handling
     */
    reactForm?: UseFormReturn<any>;
    /**
     * Works if `reactForm` is provided.
     * If true, require at least one field in the current step to be dirty to enable Next
     *
     * @default false
     */
    requireDirtyOnStep?: boolean;
    /**
     * The number of steps to render, if not provided, it will be the number of direct StepperStep children inside StepperContent
     */
    numberOfSteps?: number;
    /**
     * If true, clicking on StepperTrigger cannot move to a forward step.
     * Users must use the Next button to advance. Backward navigation via trigger remains allowed.
     *
     * We advise you to use this feature for your forms to make sure users don't skip steps.
     *
     * @default false
     */
    disableForwardNav?: boolean;
}

function Stepper({
    step: controlledActiveStep,
    onStepChange,
    children,
    reactForm,
    requireDirtyOnStep = false,
    numberOfSteps,
    disableForwardNav = false,
}: StepperProps) {
    const [internalActiveStep, setInternalActiveStep] = useState<number>(1);
    const [stepLength, setStepLength] = useState<number | null>(numberOfSteps ?? null);

    const activeStep = controlledActiveStep ?? internalActiveStep;

    const handleStepChange = useCallback(
        (step: number): void => {
            if (onStepChange) {
                onStepChange(step);
            } else {
                setInternalActiveStep(step);
            }
        },
        [onStepChange, setInternalActiveStep]
    );

    const moveNext = useCallback(() => {
        handleStepChange(activeStep + 1);
    }, [activeStep, handleStepChange]);

    const movePrevious = useCallback(() => {
        handleStepChange(activeStep - 1);
    }, [activeStep, handleStepChange]);

    const contextValue = useMemo(
        () => ({
            activeStep,
            stepLength,
            setStepLength: (length: number) => setStepLength(length),
            onStepChange: handleStepChange,
            moveNext,
            movePrevious,
            isFirstStep: () => activeStep === 1,
            isLastStep: () => stepLength !== null && stepLength > 0 && activeStep === stepLength,
            disableForwardNav,
        }),
        [activeStep, stepLength, handleStepChange, moveNext, movePrevious, disableForwardNav]
    );

    const formContextValue = useStepperFormContext({ reactForm, activeStep, requireDirtyOnStep });

    return (
        <StepperContext.Provider value={contextValue}>
            <StepperFormContext.Provider value={formContextValue}>{children}</StepperFormContext.Provider>
        </StepperContext.Provider>
    );
}

function StepperContent({ children, ...props }: React.ComponentPropsWithoutRef<'div'>) {
    const { containerRef, selectedChild } = useStepperContentBase(children);
    return (
        <div ref={containerRef} {...props}>
            {selectedChild}
        </div>
    );
}

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

    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(
    containerRef: React.RefObject<HTMLDivElement | null>,
    activeStep: number,
    dependency?: unknown
) {
    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]);
}

// Shared base for StepperContent and StepperMotionContent
function useStepperContentBase(children: React.ReactNode) {
    const { activeStep, setStepLength, stepLength } = useStepper();
    const { detectStepFieldNames } = useStepperForm();
    const containerRef = React.useRef<HTMLDivElement | null>(null);

    const stepChildren = useMemo(() => {
        const allChildren = React.Children.toArray(children);
        return allChildren.filter(
            (child): child is React.ReactElement<StepperStepProps & { children: React.ReactNode }> =>
                React.isValidElement(child) && child.type === StepperStep
        );
    }, [children]);

    useEffect(() => {
        const length = stepChildren.length;
        if (length !== stepLength) setStepLength(length);
        detectStepFieldNames(stepChildren);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [stepChildren, stepLength, setStepLength, detectStepFieldNames]);

    const selectedChild = useMemo(() => {
        let selected: React.ReactNode | null = null;
        for (let index = 0; index < stepChildren.length; index++) {
            const child = stepChildren[index];
            if (!child || !React.isValidElement(child)) continue;
            const stepNumber =
                typeof (child.props as any)?.step === 'number' ? (child.props as any).step : index + 1;
            if (stepNumber === activeStep) {
                selected = child;
                break;
            }
        }
        return selected;
    }, [activeStep, stepChildren]);

    useStepperAutoFocusNext(containerRef, activeStep, selectedChild);

    return { containerRef, selectedChild } as const;
}

export interface StepperStepProps {
    asChild?: boolean;
    /**
     * The assicated step number, if not provided, we will use the direct child index of StepperContent
     */
    step?: number;
}

/**
 * Must be the direct child of StepperContent
 */
function StepperStep({ asChild, children }: StepperStepProps & { children: React.ReactNode }) {
    const Comp = asChild ? Slot : 'div';
    return <Comp>{children}</Comp>;
}

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;
    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,
            ...props
        },
        ref
    ) => {
        const { activeStep, moveNext, isLastStep } = useStepper();
        const { isDirtyGateOpen, validateStep, blockedSteps, reactForm } = useStepperForm();

        const handleNextClick = useCallback(
            async (e: React.MouseEvent<HTMLButtonElement>) => {
                const isLast = isLastStep();
                if (!isLast || !reactForm) {
                    e.preventDefault();
                    e.stopPropagation();
                }
                
                if (canGoNext && !(await canGoNext(activeStep))) return;
                
                onClick?.(e, activeStep);

                const valid = await validateStep();
                if (isLast) return;
                if (valid) moveNext();
            },
            [activeStep, moveNext, onClick, reactForm, isLastStep, validateStep, canGoNext]
        );

        const isLast = isLastStep();

        if (isLast) {
            if (replaceForLast) return replaceForLast;
            if (reactForm) {
                return (
                    <Button
                        ref={ref}
                        aria-label="Submit"
                        role="button"
                        type="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>
    );
}

const StepperActiveStep = () => {
    const { activeStep } = useStepper();
    return <>{activeStep}</>;
};
const StepperStepLength = () => {
    const { stepLength } = useStepper();
    return <>{stepLength}</>;
};

export {
    Stepper,
    StepperActiveStep,
    StepperContent,
    StepperMotionContent,
    StepperNext,
    StepperPrevious,
    StepperStep,
    StepperStepLength,
    StepperTrigger,
};

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 type={1}> <>{/* Step content 1 */}</> </StepperStep> <StepperStep type={2}> <>{/* Step content 2 */}</> </StepperStep> <StepperStep type={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

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
asChild
boolean
step
number

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
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 10/17/2025