UIPackage

Stepper

React navigation
Edit on GitHub

Multi-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

$ npx 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