UIPackage

Stepper

Vue 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 React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/stepper.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/stepper

Examples

Props

Name Type / Values Default Required
status
'pending''active''completed''error'
pending optional
steps StepperStep[] () => [], modelValue: 1, orientation: 'horizontal', size:… optional
modelValue number optional
orientation StepperOrientation optional
size StepperSize optional
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

StepperContext
interface StepperContext {
  orientation: Ref<StepperOrientation>
  size: Ref<StepperSize>
  activeStep: Ref<number>
  steps: Ref<StepperStep[]>
  goToStep: (stepIndex: number) => void
  isClickable: (index: number) => boolean
  getStatus: (index: number) => StepperStatus
}
StepperStep
interface StepperStep {
  id: string | number
  title: string
  description?: string
  icon?: Component
  disabled?: boolean
  error?: boolean
}

Dependencies

Used by

Files (12)

  • app/components/ui/stepper/Stepper.vue 2.1 kB
    <script setup lang="ts">
    import { computed, provide, toRef } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { STEPPER_CONTEXT, type StepperOrientation, type StepperSize, type StepperStatus } from './context'
    import type { StepperStep } from './types'
    
    interface Props {
      steps?: StepperStep[]
      modelValue?: number
      orientation?: StepperOrientation
      size?: StepperSize
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      steps: () => [],
      modelValue: 1,
      orientation: 'horizontal',
      size: 'default',
    })
    
    const emit = defineEmits<{
      'update:modelValue': [value: number]
    }>()
    
    const activeStep = computed(() => props.modelValue)
    const stepsRef = computed(() => props.steps)
    
    function getStatus(index: number): StepperStatus {
      const step = props.steps[index]
      if (step?.error) return 'error'
      if (index + 1 === activeStep.value) return 'active'
      if (index + 1 < activeStep.value) return 'completed'
      return 'pending'
    }
    
    function isClickable(index: number): boolean {
      return index + 1 < activeStep.value
    }
    
    function goToStep(stepIndex: number) {
      if (stepIndex < 1 || stepIndex > props.steps.length) return
      const step = props.steps[stepIndex - 1]
      if (step?.disabled) return
      emit('update:modelValue', stepIndex)
    }
    
    provide(STEPPER_CONTEXT, {
      orientation: toRef(props, 'orientation'),
      size: toRef(props, 'size'),
      activeStep,
      steps: stepsRef,
      goToStep,
      isClickable,
      getStatus,
    })
    
    defineExpose({ goToStep })
    </script>
    
    <template>
      <div
        :class="cn('w-full', props.class)"
        role="tablist"
        :aria-orientation="orientation"
        :data-orientation="orientation"
      >
        <!-- Header strip with steps -->
        <slot name="steps">
          <ol
            v-if="steps.length > 0"
            :class="cn('flex', orientation === 'horizontal' ? 'flex-row items-start' : 'flex-col items-stretch')"
          >
            <StepperItem v-for="(step, index) in steps" :key="step.id" :step="step" :index="index" />
          </ol>
        </slot>
    
        <!-- Content area -->
        <div v-if="$slots.default" class="mt-6 flex-1">
          <slot :active-step="activeStep" :steps="steps" />
        </div>
      </div>
    </template>
  • app/components/ui/stepper/StepperItem.vue 4.2 kB
    <script setup lang="ts">
    import { computed, inject } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { STEPPER_CONTEXT } from './context'
    import type { StepperStep } from './types'
    
    interface Props {
      step: StepperStep
      index: number
      class?: HTMLAttributes['class']
    }
    
    const props = defineProps<Props>()
    
    const _maybeCtx = inject(STEPPER_CONTEXT)
    if (!_maybeCtx) throw new Error('StepperItem must be used inside <Stepper>')
    const ctx: NonNullable<typeof _maybeCtx> = _maybeCtx
    
    const status = computed(() => ctx.getStatus(props.index))
    const orientation = computed(() => ctx.orientation.value)
    const isFirst = computed(() => props.index === 0)
    const isLast = computed(() => props.index === ctx.steps.value.length - 1)
    const clickable = computed(() => ctx.isClickable(props.index) && !props.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 = computed(() => props.index < ctx.activeStep.value)
    const rightSegmentCompleted = computed(() => props.index < ctx.activeStep.value - 1)
    
    function handleNavigate() {
      if (clickable.value) ctx.goToStep(props.index + 1)
    }
    </script>
    
    <template>
      <li
        :class="
          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',
            props.class,
          )
        "
        role="tab"
        :aria-selected="status === 'active'"
        :aria-disabled="step.disabled || undefined"
        :data-status="status"
      >
        <!-- Indicator row: contains the indicator + connector segments -->
        <div
          :class="
            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) -->
          <span
            v-if="!isFirst"
            aria-hidden="true"
            :class="
              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',
              )
            "
          />
          <span
            v-if="!isLast"
            aria-hidden="true"
            :class="
              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"
            class="focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
            @click="handleNavigate"
          />
        </div>
    
        <!-- Title + description -->
        <div :class="cn('min-w-0', orientation === 'horizontal' ? 'max-w-[12rem] text-center' : 'flex-1 pt-1.5')">
          <button
            type="button"
            :class="
              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"
            @click="handleNavigate"
          >
            {{ step.title }}
          </button>
          <p v-if="step.description" class="text-muted-foreground mt-0.5 text-xs text-balance">
            {{ step.description }}
          </p>
        </div>
      </li>
    </template>
  • app/components/ui/stepper/StepperIndicator.vue 1.5 kB
    <script setup lang="ts">
    import { computed } from 'vue'
    import type { Component, HTMLAttributes } from 'vue'
    import { Check, X } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import { stepperIndicatorVariants } from './stepper.variants'
    import type { StepperSize, StepperStatus } from './context'
    
    interface Props {
      status?: StepperStatus
      size?: StepperSize
      index?: number
      icon?: Component
      clickable?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      status: 'pending',
      size: 'default',
      clickable: false,
    })
    
    const emit = defineEmits<{
      click: []
    }>()
    
    const fallbackIcon = computed(() => {
      if (props.icon) return props.icon
      if (props.status === 'completed') return Check
      if (props.status === 'error') return X
      return null
    })
    </script>
    
    <template>
      <button
        type="button"
        :class="
          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',
            props.class,
          )
        "
        :disabled="!clickable"
        :aria-current="status === 'active' ? 'step' : undefined"
        @click="emit('click')"
      >
        <slot>
          <component :is="fallbackIcon" v-if="fallbackIcon" class="size-4" aria-hidden="true" />
          <span v-else-if="index !== undefined" class="font-medium">{{ index }}</span>
        </slot>
      </button>
    </template>
  • app/components/ui/stepper/StepperHeader.vue 0.6 kB
    <!--
      StepperHeader Component
      
      Container for the stepper header row. Used to wrap StepperItems.
      
      @example
      <StepperHeader>
        <StepperItem>...</StepperItem>
        <StepperSeparator />
        <StepperItem>...</StepperItem>
      </StepperHeader>
    -->
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    interface Props {
      class?: HTMLAttributes['class']
    }
    
    const props = defineProps<Props>()
    </script>
    
    <template>
      <div :class="cn('stepper-header flex items-center gap-0', props.class)">
        <slot />
      </div>
    </template>
  • app/components/ui/stepper/StepperContent.vue 0.8 kB
    <!--
      StepperContent Component
      
      Wrapper for step content with transition animations.
      
      @example
      <StepperContent :step="currentStep">
        <div class="step-content">
          Step content here
        </div>
      </StepperContent>
    -->
    <script setup lang="ts">
    import { computed } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    interface Props {
      step?: number
      activeStep?: number
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      step: 1,
      activeStep: 1,
    })
    
    const isActive = computed(() => props.step === props.activeStep)
    </script>
    
    <template>
      <div
        v-show="isActive"
        :class="cn('stepper-content motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200', props.class)"
        role="tabpanel"
        :aria-hidden="!isActive"
      >
        <slot />
      </div>
    </template>
  • app/components/ui/stepper/StepperTitle.vue 0.4 kB
    <!--
      StepperTitle Component
      
      Title text for a stepper item.
      
      @example
      <StepperTitle>Account Setup</StepperTitle>
    -->
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    interface Props {
      class?: HTMLAttributes['class']
    }
    
    const props = defineProps<Props>()
    </script>
    
    <template>
      <span :class="cn('text-foreground text-sm font-medium', props.class)">
        <slot />
      </span>
    </template>
  • app/components/ui/stepper/StepperDescription.vue 0.5 kB
    <!--
      StepperDescription Component
      
      Description text for a stepper item.
      
      @example
      <StepperDescription>Create your account to get started</StepperDescription>
    -->
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    interface Props {
      class?: HTMLAttributes['class']
    }
    
    const props = defineProps<Props>()
    </script>
    
    <template>
      <span :class="cn('text-muted-foreground text-xs', props.class)">
        <slot />
      </span>
    </template>
  • app/components/ui/stepper/StepperStep.vue 2.6 kB
    <!--
      StepperStep Component
      
      A single step within a Stepper, used with step slots.
      Provides title, description, completion state, and active state.
      
      @example
      <Stepper v-model="currentStep" orientation="vertical">
        <StepperStep 
          title="Account" 
          description="Create your account"
          :completed="currentStep > 1" 
          :active="currentStep === 1"
          :error="hasAccountError"
        >
          <AccountForm @next="currentStep = 2" />
        </StepperStep>
        <StepperStep 
          title="Profile" 
          description="Set up your profile"
          :completed="currentStep > 2" 
          :active="currentStep === 2"
        >
          <ProfileForm @next="currentStep = 3" />
        </StepperStep>
        <StepperStep 
          title="Confirm" 
          description="Review and confirm"
          :completed="currentStep > 3" 
          :active="currentStep === 3"
        >
          <Confirmation @submit="handleSubmit" />
        </StepperStep>
      </Stepper>
    -->
    <script setup lang="ts">
    import { computed } from 'vue'
    import type { Component, HTMLAttributes } from 'vue'
    import { Check } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import { stepperIndicatorVariants } from './stepper.variants'
    
    interface Props {
      title: string
      description?: string
      icon?: Component
      completed?: boolean
      active?: boolean
      error?: boolean
      disabled?: boolean
      status?: 'active' | 'completed' | 'pending' | 'error'
      index?: number
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      completed: false,
      active: false,
      error: false,
      disabled: false,
      index: undefined,
    })
    
    const computedStatus = computed(() => {
      if (props.status) return props.status
      if (props.error) return 'error'
      if (props.active) return 'active'
      if (props.completed) return 'completed'
      return 'pending'
    })
    </script>
    
    <template>
      <div :class="cn('stepper-step flex gap-3', props.class)" role="tab" :aria-selected="active" :aria-disabled="disabled">
        <!-- Indicator -->
        <div
          :class="
            cn(
              stepperIndicatorVariants({
                status: computedStatus,
                size: 'default',
              }),
            )
          "
        >
          <slot name="icon">
            <Check v-if="computedStatus === 'completed'" class="size-4" aria-hidden="true" />
            <span v-else-if="index">{{ index }}</span>
          </slot>
        </div>
    
        <!-- Content -->
        <div class="flex flex-col gap-0.5 pt-1">
          <slot name="title">
            <span class="text-sm font-medium">{{ title }}</span>
          </slot>
          <slot name="description">
            <span v-if="description" class="text-muted-foreground text-xs">
              {{ description }}
            </span>
          </slot>
        </div>
      </div>
    </template>
  • app/components/ui/stepper/context.ts 0.6 kB
    import type { InjectionKey, Ref } from 'vue'
    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 StepperContext {
      orientation: Ref<StepperOrientation>
      size: Ref<StepperSize>
      activeStep: Ref<number>
      steps: Ref<StepperStep[]>
      goToStep: (stepIndex: number) => void
      isClickable: (index: number) => boolean
      getStatus: (index: number) => StepperStatus
    }
    
    export const STEPPER_CONTEXT: InjectionKey<StepperContext> = Symbol('StepperContext')
  • app/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>
  • app/components/ui/stepper/types.ts 0.2 kB
    import type { Component } from 'vue'
    
    export interface StepperStep {
      id: string | number
      title: string
      description?: string
      icon?: Component
      disabled?: boolean
      error?: boolean
    }
  • app/components/ui/stepper/index.ts 0.9 kB
    export { default as Stepper } from './Stepper.vue'
    export { default as StepperHeader } from './StepperHeader.vue'
    export { default as StepperItem } from './StepperItem.vue'
    export { default as StepperIndicator } from './StepperIndicator.vue'
    export { default as StepperContent } from './StepperContent.vue'
    export { default as StepperTitle } from './StepperTitle.vue'
    export { default as StepperDescription } from './StepperDescription.vue'
    export { default as StepperStep } from './StepperStep.vue'
    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
    // StepperIndicator.vue <-> index.ts circular import that broke dev SSR).
    export { stepperIndicatorVariants, type StepperIndicatorVariants } from './stepper.variants'

Raw manifest: https://uipkge.dev/r/vue/stepper.json