Stepper
React navigationMulti-step indicator — horizontal or vertical, with completed / current / upcoming states and optional descriptions per step. Use for onboarding wizards and checkout flows.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/stepper.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/stepper.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/stepper.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/stepper.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/stepper
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
status | 'pending''active''completed''error' | pending | optional |
size | 'sm''default''lg' | default | optional |
steps | StepperStepConfig[] | — | optional |
value | number | 1 | optional |
onValueChange | (value: number) => void | — | optional |
orientation | StepperOrientation | horizontal | optional |
className | string | mt-6 flex-1 | optional |
stepsSlot Custom header strip. Falls back to the auto-rendered `<ol>` of items. | React.ReactNode | — | optional |
children Content area. Receives the active step + steps for convenience. | React.ReactNode | ((args: { activeStep: number; steps: StepperStepConfig[] }) => React.ReactNode) | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
StepperContextValue interface StepperContextValue {
orientation: StepperOrientation
size: StepperSize
activeStep: number
steps: StepperStep[]
goToStep: (stepIndex: number) => void
isClickable: (index: number) => boolean
getStatus: (index: number) => StepperStatus
} StepperStep interface StepperStep {
id: string | number
title: string
description?: string
icon?: LucideIcon
disabled?: boolean
error?: boolean
} Dependencies
Used by
Files (5)
-
components/ui/stepper/stepper.tsx 13.6 kB
'use client' import * as React from 'react' import { Check, X, type LucideIcon } from 'lucide-react' import { cn } from '@/lib/utils' import { stepperIndicatorVariants } from './stepper.variants' import { StepperContext, useStepperContext, type StepperOrientation, type StepperSize, type StepperStatus, } from './context' // Aliased: the standalone <StepperStep> component (below) owns the bare name. import type { StepperStep as StepperStepConfig } from './types' /* ------------------------------------------------------------------ */ /* Stepper (root) */ /* ------------------------------------------------------------------ */ export interface StepperProps { steps?: StepperStepConfig[] value?: number onValueChange?: (value: number) => void orientation?: StepperOrientation size?: StepperSize className?: string /** Custom header strip. Falls back to the auto-rendered `<ol>` of items. */ stepsSlot?: React.ReactNode /** Content area. Receives the active step + steps for convenience. */ children?: React.ReactNode | ((args: { activeStep: number; steps: StepperStepConfig[] }) => React.ReactNode) } const Stepper = React.forwardRef<HTMLDivElement, StepperProps>( ( { steps = [], value = 1, onValueChange, orientation = 'horizontal', size = 'default', className, stepsSlot, children }, ref, ) => { const activeStep = value const getStatus = React.useCallback( (index: number): StepperStatus => { const step = steps[index] if (step?.error) return 'error' if (index + 1 === activeStep) return 'active' if (index + 1 < activeStep) return 'completed' return 'pending' }, [steps, activeStep], ) const isClickable = React.useCallback((index: number): boolean => index + 1 < activeStep, [activeStep]) const goToStep = React.useCallback( (stepIndex: number) => { if (stepIndex < 1 || stepIndex > steps.length) return const step = steps[stepIndex - 1] if (step?.disabled) return onValueChange?.(stepIndex) }, [steps, onValueChange], ) const ctx = React.useMemo( () => ({ orientation, size, activeStep, steps, goToStep, isClickable, getStatus }), [orientation, size, activeStep, steps, goToStep, isClickable, getStatus], ) return ( <StepperContext.Provider value={ctx}> <div ref={ref} className={cn('w-full', className)} role="tablist" aria-orientation={orientation} data-orientation={orientation} > {/* Header strip with steps */} {stepsSlot ?? (steps.length > 0 && ( <ol className={cn( 'flex', orientation === 'horizontal' ? 'flex-row items-start' : 'flex-col items-stretch', )} > {steps.map((step, index) => ( <StepperItem key={step.id} step={step} index={index} /> ))} </ol> ))} {/* Content area */} {children != null && ( <div className="mt-6 flex-1"> {typeof children === 'function' ? children({ activeStep, steps }) : children} </div> )} </div> </StepperContext.Provider> ) }, ) Stepper.displayName = 'Stepper' /* ------------------------------------------------------------------ */ /* StepperIndicator */ /* ------------------------------------------------------------------ */ export interface StepperIndicatorProps { status?: StepperStatus size?: StepperSize index?: number icon?: LucideIcon clickable?: boolean className?: string onClick?: () => void children?: React.ReactNode } const StepperIndicator = React.forwardRef<HTMLButtonElement, StepperIndicatorProps>( ({ status = 'pending', size = 'default', index, icon: Icon, clickable = false, className, onClick, children }, ref) => { const FallbackIcon: LucideIcon | null = Icon ?? (status === 'completed' ? Check : status === 'error' ? X : null) return ( <button ref={ref} type="button" className={cn( stepperIndicatorVariants({ status, size }), 'ring-background relative z-10 ring-4 transition-colors duration-200 outline-none', clickable && 'focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none', !clickable && 'cursor-default', className, )} disabled={!clickable} aria-current={status === 'active' ? 'step' : undefined} onClick={onClick} > {children ?? (FallbackIcon ? ( <FallbackIcon className="size-4" aria-hidden="true" /> ) : index !== undefined ? ( <span className="font-medium">{index}</span> ) : null)} </button> ) }, ) StepperIndicator.displayName = 'StepperIndicator' /* ------------------------------------------------------------------ */ /* StepperItem */ /* ------------------------------------------------------------------ */ export interface StepperItemProps { step: StepperStepConfig index: number className?: string } const StepperItem = React.forwardRef<HTMLLIElement, StepperItemProps>(({ step, index, className }, ref) => { const ctx = useStepperContext() const status = ctx.getStatus(index) const orientation = ctx.orientation const isFirst = index === 0 const isLast = index === ctx.steps.length - 1 const clickable = ctx.isClickable(index) && !step.disabled // A connector "segment" is the line drawn between this indicator and the // adjacent one. We split it into left/right halves so each item owns its // own piece — they butt up at item boundaries for pixel alignment. const leftSegmentCompleted = index < ctx.activeStep const rightSegmentCompleted = index < ctx.activeStep - 1 function handleNavigate() { if (clickable) ctx.goToStep(index + 1) } return ( <li ref={ref} className={cn( 'group/stepper-item relative min-w-0', orientation === 'horizontal' ? 'flex flex-1 flex-col items-center gap-2' : 'flex flex-row items-start gap-3 pb-6 last:pb-0', step.disabled && 'opacity-50', className, )} role="tab" aria-selected={status === 'active'} aria-disabled={step.disabled || undefined} data-status={status} > {/* Indicator row: contains the indicator + connector segments */} <div className={cn( 'relative flex shrink-0', orientation === 'horizontal' ? 'h-9 w-full items-center justify-center' : 'w-9 flex-col items-center justify-start self-stretch', )} > {/* Connector segments (absolute, butt up at item boundaries) */} {!isFirst && ( <span aria-hidden="true" className={cn( 'pointer-events-none absolute transition-colors duration-200', orientation === 'horizontal' ? 'top-1/2 right-1/2 left-0 h-px -translate-y-1/2' : 'top-0 bottom-1/2 left-1/2 w-px -translate-x-1/2', leftSegmentCompleted ? 'bg-primary' : 'bg-border', )} /> )} {!isLast && ( <span aria-hidden="true" className={cn( 'pointer-events-none absolute transition-colors duration-200', orientation === 'horizontal' ? 'top-1/2 right-0 left-1/2 h-px -translate-y-1/2' : 'top-1/2 bottom-0 left-1/2 w-px -translate-x-1/2', rightSegmentCompleted ? 'bg-primary' : 'bg-border', )} /> )} <StepperIndicator status={status} index={index + 1} icon={step.icon} clickable={clickable} className="focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" onClick={handleNavigate} /> </div> {/* Title + description */} <div className={cn('min-w-0', orientation === 'horizontal' ? 'max-w-[12rem] text-center' : 'flex-1 pt-1.5')}> <button type="button" className={cn( 'text-foreground text-sm font-medium text-balance transition-colors outline-none', clickable && 'hover:text-primary focus-visible:text-primary focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none', !clickable && 'cursor-default', status === 'pending' && 'text-muted-foreground', status === 'error' && 'text-destructive', )} disabled={!clickable} onClick={handleNavigate} > {step.title} </button> {step.description && ( <p className="text-muted-foreground mt-0.5 text-xs text-balance">{step.description}</p> )} </div> </li> ) }) StepperItem.displayName = 'StepperItem' /* ------------------------------------------------------------------ */ /* StepperHeader */ /* ------------------------------------------------------------------ */ const StepperHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, ...props }, ref) => ( <div ref={ref} className={cn('stepper-header flex items-center gap-0', className)} {...props} /> ), ) StepperHeader.displayName = 'StepperHeader' /* ------------------------------------------------------------------ */ /* StepperContent */ /* ------------------------------------------------------------------ */ export interface StepperContentProps extends React.HTMLAttributes<HTMLDivElement> { step?: number activeStep?: number } const StepperContent = React.forwardRef<HTMLDivElement, StepperContentProps>( ({ step = 1, activeStep = 1, className, children, ...props }, ref) => { const isActive = step === activeStep return ( <div ref={ref} className={cn( 'stepper-content motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200', className, )} style={!isActive ? { display: 'none' } : undefined} role="tabpanel" aria-hidden={!isActive} {...props} > {children} </div> ) }, ) StepperContent.displayName = 'StepperContent' /* ------------------------------------------------------------------ */ /* StepperTitle */ /* ------------------------------------------------------------------ */ const StepperTitle = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>( ({ className, ...props }, ref) => ( <span ref={ref} className={cn('text-foreground text-sm font-medium', className)} {...props} /> ), ) StepperTitle.displayName = 'StepperTitle' /* ------------------------------------------------------------------ */ /* StepperDescription */ /* ------------------------------------------------------------------ */ const StepperDescription = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>( ({ className, ...props }, ref) => ( <span ref={ref} className={cn('text-muted-foreground text-xs', className)} {...props} /> ), ) StepperDescription.displayName = 'StepperDescription' /* ------------------------------------------------------------------ */ /* StepperStep (standalone, slot-driven) */ /* ------------------------------------------------------------------ */ export interface StepperStepProps { title: string description?: string icon?: React.ReactNode completed?: boolean active?: boolean error?: boolean disabled?: boolean status?: 'active' | 'completed' | 'pending' | 'error' index?: number className?: string titleSlot?: React.ReactNode descriptionSlot?: React.ReactNode iconSlot?: React.ReactNode } const StepperStep = React.forwardRef<HTMLDivElement, StepperStepProps>( ( { title, description, completed = false, active = false, error = false, disabled = false, status, index, className, titleSlot, descriptionSlot, iconSlot, }, ref, ) => { const computedStatus: StepperStatus = status ? status : error ? 'error' : active ? 'active' : completed ? 'completed' : 'pending' return ( <div ref={ref} className={cn('stepper-step flex gap-3', className)} role="tab" aria-selected={active} aria-disabled={disabled} > {/* Indicator */} <div className={cn(stepperIndicatorVariants({ status: computedStatus, size: 'default' }))}> {iconSlot ?? (computedStatus === 'completed' ? ( <Check className="size-4" aria-hidden="true" /> ) : index ? ( <span>{index}</span> ) : null)} </div> {/* Content */} <div className="flex flex-col gap-0.5 pt-1"> {titleSlot ?? <span className="text-sm font-medium">{title}</span>} {descriptionSlot ?? (description && <span className="text-muted-foreground text-xs">{description}</span>)} </div> </div> ) }, ) StepperStep.displayName = 'StepperStep' export { Stepper, StepperItem, StepperIndicator, StepperHeader, StepperContent, StepperTitle, StepperDescription, StepperStep, } -
components/ui/stepper/context.ts 0.8 kB
'use client' import * as React from 'react' import type { StepperStep } from './types' export type StepperOrientation = 'horizontal' | 'vertical' export type StepperStatus = 'active' | 'completed' | 'pending' | 'error' export type StepperSize = 'sm' | 'default' | 'lg' export interface StepperContextValue { orientation: StepperOrientation size: StepperSize activeStep: number steps: StepperStep[] goToStep: (stepIndex: number) => void isClickable: (index: number) => boolean getStatus: (index: number) => StepperStatus } export const StepperContext = React.createContext<StepperContextValue | null>(null) export function useStepperContext(): StepperContextValue { const ctx = React.useContext(StepperContext) if (!ctx) throw new Error('StepperItem must be used inside <Stepper>') return ctx } -
components/ui/stepper/stepper.variants.ts 1.1 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so `StepperIndicator.vue` / `StepperStep.vue` can import * them without creating a circular dependency back through the index. * Sibling pattern to `card/card.variants.ts`. */ export const stepperIndicatorVariants = cva('flex items-center justify-center rounded-full font-semibold shrink-0', { variants: { status: { pending: 'bg-muted text-muted-foreground', active: 'bg-primary text-primary-foreground shadow-sm', completed: 'bg-primary text-primary-foreground', error: 'bg-destructive text-destructive-foreground', }, size: { sm: 'size-7 text-xs [&>svg]:size-3.5', default: 'size-9 text-sm [&>svg]:size-4', lg: 'size-11 text-base [&>svg]:size-5', }, }, defaultVariants: { status: 'pending', size: 'default', }, }) export type StepperIndicatorVariants = VariantProps<typeof stepperIndicatorVariants> -
components/ui/stepper/types.ts 0.2 kB
import type { LucideIcon } from 'lucide-react' export interface StepperStep { id: string | number title: string description?: string icon?: LucideIcon disabled?: boolean error?: boolean } -
components/ui/stepper/index.ts 0.7 kB
export { Stepper, StepperHeader, StepperItem, StepperIndicator, StepperContent, StepperTitle, StepperDescription, StepperStep, type StepperProps, type StepperItemProps, type StepperIndicatorProps, type StepperContentProps, type StepperStepProps, } from './stepper' export type { StepperStep as StepperStepConfig } from './types' export type { StepperOrientation, StepperSize, StepperStatus } from './context' // Re-export variant API from the sibling file (kept separate to avoid the // stepper.tsx <-> index.ts circular import that broke dev SSR in the Vue source). export { stepperIndicatorVariants, type StepperIndicatorVariants } from './stepper.variants'
Raw manifest: https://react.uipkge.dev/r/react/stepper.json