shadimage
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,
}

More from FormCn

Browse all components in this library →