FormCn
componentsteppersource: ok · refreshed
Stepper
Stepper component
npx shadcn add "https://formcn.dev/r/stepper.json"free
Where it goes
onboarding
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 7:43:45 AM
src/components/ui/stepper.tsx
import { Slot } from '@radix-ui/react-slot'
import { CheckIcon, LoaderCircleIcon } from 'lucide-react'
import * as React from 'react'
import { createContext, useContext } from 'react'
import { cn } from '@/lib/utils'
// Types
type StepperContextValue = {
activeStep: number
setActiveStep: (step: number) => void
orientation: 'horizontal' | 'vertical'
}
type StepItemContextValue = {
step: number
state: StepState
isDisabled: boolean
isLoading: boolean
}
type StepState = 'active' | 'completed' | 'inactive' | 'loading'
// Contexts
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined,
)
const useStepper = () => {
const context = useContext(StepperContext)
if (!context) {
throw new Error('useStepper must be used within a Stepper')
}
return context
}
const useStepItem = () => {
const context = useContext(StepItemContext)
if (!context) {
throw new Error('useStepItem must be used within a StepperItem')
}
return context
}
// Components
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: 'horizontal' | 'vertical'
}
function Stepper({
defaultValue = 0,
value,
onValueChange,
orientation = 'horizontal',
className,
...props
}: StepperProps) {
const [activeStep, setInternalStep] = React.useState(defaultValue)
const setActiveStep = React.useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange],
)
const currentStep = value ?? activeStep
return (
<StepperContext.Provider
value={{
activeStep: currentStep,
orientation,
setActiveStep,
}}
>
<div
className={cn(
'group/stepper inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col',
className,
)}
data-orientation={orientation}
data-slot="stepper"
{...props}
/>
</StepperContext.Provider>
)
}
// StepperItem
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number
completed?: boolean
disabled?: boolean
loading?: boolean
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper()
const state: StepState =
completed || step < activeStep
? 'completed'
: activeStep === step
? 'active'
: 'inactive'
const isLoading = loading && step === activeStep
return (
<StepItemContext.Provider
value={{ isDisabled: disabled, isLoading, state, step }}
>
<div
className={cn(
'group/step flex items-center group-data-[orientation=horizontal]/stepper:flex-row group-data-[orientation=vertical]/stepper:flex-col',
className,
)}
data-slot="stepper-item"
data-state={state}
{...(isLoading ? { 'data-loading': true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
)
}
// StepperTrigger
interface StepperTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
function StepperTrigger({
asChild = false,
className,
children,
...props
}: StepperTriggerProps) {
const { setActiveStep } = useStepper()
const { step, isDisabled } = useStepItem()
if (asChild) {
const Comp = asChild ? Slot : 'span'
return (
<Comp className={className} data-slot="stepper-trigger">
{children}
</Comp>
)
}
return (
<button
className={cn(
'inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
className,
)}
data-slot="stepper-trigger"
disabled={isDisabled}
onClick={() => setActiveStep(step)}
type="button"
{...props}
>
{children}
</button>
)
}
// StepperIndicator
interface StepperIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean
}
function StepperIndicator({
asChild = false,
className,
children,
...props
}: StepperIndicatorProps) {
const { state, step, isLoading } = useStepItem()
return (
<span
className={cn(
'relative flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs data-[state=active]:bg-primary data-[state=completed]:bg-primary data-[state=active]:text-primary-foreground data-[state=completed]:text-primary-foreground',
className,
)}
data-slot="stepper-indicator"
data-state={state}
{...props}
>
{asChild ? (
children
) : (
<>
<span className="transition-all group-data-[state=completed]/step:scale-0 group-data-loading/step:scale-0 group-data-[state=completed]/step:opacity-0 group-data-loading/step:opacity-0 group-data-loading/step:transition-none">
{step}
</span>
<CheckIcon
aria-hidden="true"
className="absolute scale-0 opacity-0 transition-all group-data-[state=completed]/step:scale-100 group-data-[state=completed]/step:opacity-100"
size={16}
/>
{isLoading && (
<span className="absolute transition-all">
<LoaderCircleIcon
aria-hidden="true"
className="animate-spin"
size={14}
/>
</span>
)}
</>
)}
</span>
)
}
// StepperTitle
function StepperTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn('font-medium text-sm', className)}
data-slot="stepper-title"
{...props}
/>
)
}
// StepperDescription
function StepperDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn('text-muted-foreground text-sm', className)}
data-slot="stepper-description"
{...props}
/>
)
}
// StepperSeparator
function StepperSeparator({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'm-0.5 bg-muted group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=vertical]/stepper:h-12 group-data-[orientation=horizontal]/stepper:w-full group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=horizontal]/stepper:flex-1 group-data-[state=completed]/step:bg-primary',
className,
)}
data-slot="stepper-separator"
{...props}
/>
)
}
export {
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
}