Features
react-hook-formintegration, 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.
Make sure that the following shadcn/ui components are present in your project:
Copy and paste the following code into your project.
'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
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.
For that reason, you have to use shadcn/ui's <FormField /> component to properly integrate your form.
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
| Prop | Type | Default |
|---|---|---|
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.
Must be the direct child of StepperContent.
StepperStepProps
| Prop | Type | Default |
|---|---|---|
step | number | |
children* | React.ReactNode |
Previous
Previous step button.
StepperPreviousProps
| Prop | Type | Default |
|---|---|---|
asChild | boolean | |
onClick | function |
Next
Next step button.
StepperNextProps
| Prop | Type | Default |
|---|---|---|
asChild | boolean | |
lastChildren | React.ReactNode | |
replaceForLast | React.ReactNode | |
onClick | function | |
onLastClick | function | |
canGoNext | function |
Trigger
StepperTriggerProps
| Prop | Type | Default |
|---|---|---|
asChild | boolean | |
step* | number |
ActiveStep
A React Fragment that contains the active step number.
StepLength
A React Fragment that contains the number of steps.
A compound component for capturing and transcribing audio input using the Web Speech API with real-time visualization.
A guided tour that helps users understand the interface.
How is this guide?
Last updated on 1/8/2026
